mailertogo-spf 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +137 -1
- data/lib/mailertogo/spf/authorization.rb +8 -6
- data/lib/mailertogo/spf/chain_audit.rb +215 -0
- data/lib/mailertogo/spf/hostname.rb +72 -0
- data/lib/mailertogo/spf/merge_plan.rb +25 -26
- data/lib/mailertogo/spf/record.rb +17 -0
- data/lib/mailertogo/spf/term.rb +158 -0
- data/lib/mailertogo/spf/version.rb +1 -1
- data/lib/mailertogo/spf.rb +31 -1
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 581850ec95dfe6d56b2fa63dce987667bde0cb512818e8d5f4cd36d7492b68ab
|
|
4
|
+
data.tar.gz: 4efc0919f45c40599e045fd724b32550611cee5ae142e92f029174808fd93225
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 061357e0bb7fee158f333d4f40acd28f2dbb6306de1347450c7704e022cd1a29925c3f592d655632f5fab580b181a5ede52dc693f7d8d0bf6395a03c55d6735b
|
|
7
|
+
data.tar.gz: d7244ab5481f550f1213317fc9b4ac8b1d3d8f887bd359c4cdf6102a208fc6a99858c89e0f2aa1810dc6a0db166f50be9df9e9e6508f2f4922ec1c61048094be
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-08-17
|
|
8
|
+
|
|
9
|
+
Answers a second question about a record: not only "does it authorize me, and
|
|
10
|
+
what did that cost", but "what does it cost a receiver that has to evaluate all
|
|
11
|
+
of it" — the number every other SPF checker reports, and the one a domain owner
|
|
12
|
+
compares against.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `MailerToGo::SPF.chain_audit` / `MailerToGo::SPF::ChainAudit` — prices a whole
|
|
17
|
+
record tree against the RFC 7208 §4.6.4 budget, term by term, with a running
|
|
18
|
+
total. Deliberately a different number from `Result#lookups`, which stops
|
|
19
|
+
where the receiver stops (§4.6.2): a record can authorize you at a cost of 10
|
|
20
|
+
while costing 11 overall and being broken for everyone listed after you.
|
|
21
|
+
Reports `over_limit?`, `headroom`, `partial?` (part of the chain did not
|
|
22
|
+
resolve, so the total is a floor) and `capped?` (past `CEILING`, where the
|
|
23
|
+
exact number stops changing what anyone should do), and names the chain
|
|
24
|
+
defects found on the way: `targets_without_spf` (§5.2) and
|
|
25
|
+
`duplicated_in_chain` (§4.5). Pass `record:` to price a record that is not
|
|
26
|
+
published yet, or omit it to have the apex resolved.
|
|
27
|
+
- `MailerToGo::SPF::Term` — one term of a record as an immutable object that
|
|
28
|
+
answers questions (`include?`, `querying?`, `unreachable?`, `unknown?`,
|
|
29
|
+
`qualifier_meaning`, `all_suffix`, `kind`) instead of being re-matched at
|
|
30
|
+
every call site. Subclassable, and built through `term_class:` on both
|
|
31
|
+
`Record.parse_terms` and `ChainAudit`, so a consumer can hang its own
|
|
32
|
+
human-facing copy off a term without standing up a second parser behind it.
|
|
33
|
+
- `Record.parse_terms(record, term_class:)` — a record's terms as `Term`s,
|
|
34
|
+
numbered from 1 in record order. `Record.terms` still returns strings.
|
|
35
|
+
- `MailerToGo::SPF.normalize_hostname` / `.hostname?` (and
|
|
36
|
+
`MailerToGo::SPF::Hostname`) — the gate for untrusted input in front of
|
|
37
|
+
anything that resolves DNS on request. Forgiving about shape (a pasted URL,
|
|
38
|
+
an email address, a trailing dot), strict about the result: a syntactically
|
|
39
|
+
valid hostname, or nil. Distinct from `Record.normalize_name`, which
|
|
40
|
+
normalizes a name that came out of a record and never rejects.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- The qualifier map, the querying-mechanism list, the modifier regex and the
|
|
45
|
+
`all`-with-junk regex are now defined once, on `Term`, and read from there by
|
|
46
|
+
`Authorization` and `MergePlan`. `Authorization::QUERYING_MECHANISMS` and
|
|
47
|
+
`ALL_QUALIFIERS` keep their names and values.
|
|
48
|
+
- `MergePlan::ALL_TERM` and `MergePlan::MODIFIER_TERM` are removed; `MergePlan`
|
|
49
|
+
asks `Term` what a term is. Behaviour of the merge is unchanged.
|
|
50
|
+
|
|
7
51
|
## [0.1.0] - 2026-08-16
|
|
8
52
|
|
|
9
53
|
First release. Extracted from the SPF engine MailerToGo runs behind its own
|
|
@@ -30,4 +74,5 @@ domain setup and monitoring.
|
|
|
30
74
|
`Resolv::DNS` default and an optional `CachingResolver`. No runtime
|
|
31
75
|
dependencies, no Rails.
|
|
32
76
|
|
|
77
|
+
[0.2.0]: https://github.com/aluminumio/mailertogo-spf/releases/tag/v0.2.0
|
|
33
78
|
[0.1.0]: https://github.com/aluminumio/mailertogo-spf/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -23,6 +23,10 @@ stops at the first mechanism that matches (RFC 7208 §4.6.2), and counts
|
|
|
23
23
|
DNS-querying terms against §4.6.4's cap of 10 — the same arithmetic a receiver
|
|
24
24
|
does, so a record this gem passes is a record that passes in the wild.
|
|
25
25
|
|
|
26
|
+
It answers three questions about a name: does its SPF **authorize** you, what
|
|
27
|
+
should it **publish** given what is already there, and what does the record
|
|
28
|
+
**cost** a receiver that evaluates all of it.
|
|
29
|
+
|
|
26
30
|
No Rails. No runtime dependencies. DNS goes through an injectable resolver, so
|
|
27
31
|
your test suite never touches the network.
|
|
28
32
|
|
|
@@ -173,6 +177,138 @@ plan.value_for(name: "example.com", value: "v=spf1 include:_spf.mailertogo.net ~
|
|
|
173
177
|
# => "v=spf1 include:_spf.google.com include:_spf.mailertogo.net ~all"
|
|
174
178
|
```
|
|
175
179
|
|
|
180
|
+
## What does this record cost?
|
|
181
|
+
|
|
182
|
+
`authorize` counts the lookups spent up to the point where **you** match,
|
|
183
|
+
because §4.6.2 ends a receiver's evaluation at the first matching mechanism.
|
|
184
|
+
That is the right number for a gating decision and the wrong number for a page
|
|
185
|
+
about the record itself, where the question is what the record costs a receiver
|
|
186
|
+
that has to evaluate *all* of it — the number every other SPF checker reports.
|
|
187
|
+
|
|
188
|
+
`chain_audit` walks the whole tree and prices it against the §4.6.4 budget of
|
|
189
|
+
ten, attaching each cost to the term that incurred it:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
audit = MailerToGo::SPF.chain_audit("example.com")
|
|
193
|
+
|
|
194
|
+
audit.total # => 3
|
|
195
|
+
audit.limit # => 10
|
|
196
|
+
audit.headroom # => 7 — lookups still available before the cap
|
|
197
|
+
audit.over_limit? # => false
|
|
198
|
+
|
|
199
|
+
audit.terms.map { |t| [t.raw, t.lookups, t.running_total] }
|
|
200
|
+
# => [["include:_spf.google.com", 3, 3], # itself, plus the two includes inside it
|
|
201
|
+
# ["ip4:198.51.100.7", 0, 3], # already an address; no DNS
|
|
202
|
+
# ["~all", 0, 3]]
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
An `include:` costs one lookup **plus everything the record it pulls in costs**,
|
|
206
|
+
which is why a record with three terms can be most of the way through the
|
|
207
|
+
budget. That is the arithmetic people get wrong by hand, and the reason a
|
|
208
|
+
domain that has added one provider too many cannot see it in the record.
|
|
209
|
+
|
|
210
|
+
The two numbers can disagree about the same record, and both are right:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
# v=spf1 include:p1… ×8 include:relay.example.net include:_spf.mailertogo.net ~all
|
|
214
|
+
|
|
215
|
+
MailerToGo::SPF.authorize("example.com").lookups # => 10 — a receiver matches you and stops
|
|
216
|
+
MailerToGo::SPF.chain_audit("example.com").total # => 11 — evaluating all of it costs 11
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Your mail passes at every receiver today. The record is still over the cap, so
|
|
220
|
+
everything listed *after* your include has already stopped passing, and the
|
|
221
|
+
first person to add a provider breaks yours too. Report only the first number
|
|
222
|
+
and you tell that domain owner their record is fine.
|
|
223
|
+
|
|
224
|
+
The audit also names the defects it finds on the way down, each of which
|
|
225
|
+
permerrors the whole evaluation rather than quietly doing nothing:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
audit.targets_without_spf # => ["nothing.example.net"] — include: of a name with no SPF (§5.2)
|
|
229
|
+
audit.duplicated_in_chain # => ["two.example.net"] — two v=spf1 records in the chain (§4.5)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Two honesty flags, because a count you could not finish must never read as "it
|
|
233
|
+
fits": `partial?` (part of the chain did not resolve) and `capped?` (the record
|
|
234
|
+
is so far past the cap that we stopped resolving — 20 lookups and 200 are the
|
|
235
|
+
same record to a receiver). Either one makes `total` a floor, and `headroom`
|
|
236
|
+
returns `nil` rather than a reassuring number.
|
|
237
|
+
|
|
238
|
+
Pass `record:` to price a line that is not published yet, and omit it to have
|
|
239
|
+
the apex resolved for you:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
MailerToGo::SPF.chain_audit("example.com", record: "v=spf1 include:a.example.net include:b.example.net -all")
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Terms
|
|
246
|
+
|
|
247
|
+
A `Term` is one term of a record, asked questions instead of pattern-matched:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
terms = MailerToGo::SPF::Record.parse_terms("v=spf1 include:_spf.mailertogo.net ~all;google-site-verification=abc")
|
|
251
|
+
|
|
252
|
+
terms.first.mechanism # => "include"
|
|
253
|
+
terms.first.target # => "_spf.mailertogo.net"
|
|
254
|
+
terms.first.querying? # => true — it spends from the §4.6.4 budget
|
|
255
|
+
|
|
256
|
+
terms.last.all? # => true — still a terminal `all`, junk and all
|
|
257
|
+
terms.last.qualifier_meaning # => :softfail
|
|
258
|
+
terms.last.all_suffix # => ";google-site-verification=abc"
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
That last one is the case worth having: records ending
|
|
262
|
+
`~all;google-site-verification=…` are real and not rare, and reading the whole
|
|
263
|
+
token as junk loses the record's `all` — and with it the domain's entire policy
|
|
264
|
+
for unauthorized mail.
|
|
265
|
+
|
|
266
|
+
There is deliberately **no English sentence** on a term. A description of what a
|
|
267
|
+
term means is product voice; it belongs to whoever is writing to their own
|
|
268
|
+
customers, in their own words. What the gem gives you instead is somewhere to
|
|
269
|
+
put it — subclass `Term`, and both `Record.parse_terms` and `ChainAudit` will
|
|
270
|
+
build and price yours:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
class AnnotatedTerm < MailerToGo::SPF::Term
|
|
274
|
+
def meaning
|
|
275
|
+
return "Everything else is marked, not rejected." if all? && qualifier == "~"
|
|
276
|
+
return "Applies #{target}'s own SPF record here." if include?
|
|
277
|
+
|
|
278
|
+
…
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
MailerToGo::SPF.chain_audit("example.com", term_class: AnnotatedTerm).terms.map(&:meaning)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Untrusted input
|
|
286
|
+
|
|
287
|
+
If the hostname came from a form, an API parameter or an uploaded file rather
|
|
288
|
+
than from your own database, check it before you resolve anything: an SPF walk
|
|
289
|
+
is recursive DNS performed on request.
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
MailerToGo::SPF.normalize_hostname("https://WWW.Example.com/pricing?x=1") # => "www.example.com"
|
|
293
|
+
MailerToGo::SPF.normalize_hostname("billing@example.com") # => "example.com"
|
|
294
|
+
MailerToGo::SPF.normalize_hostname("example.com:5353") # => "example.com"
|
|
295
|
+
MailerToGo::SPF.normalize_hostname("v=spf1") # => nil
|
|
296
|
+
MailerToGo::SPF.hostname?("192.0.2.1") # => false
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Forgiving about shape — people paste URLs and email addresses into a box
|
|
300
|
+
labelled "domain" and are not wrong to expect that to work — and strict about
|
|
301
|
+
the result: a syntactically valid hostname, or `nil`. A scheme, a port, a path,
|
|
302
|
+
a query string or an address literal is stripped or refused before any resolver
|
|
303
|
+
sees it, and there is no way to name a resolver or a port through it.
|
|
304
|
+
|
|
305
|
+
It is a separate call rather than something folded into `authorize`, on purpose.
|
|
306
|
+
"That is not a hostname" is a fact about your **input**; the statuses on
|
|
307
|
+
`Result` are facts about somebody's **DNS**, and conflating the two would mean
|
|
308
|
+
inventing a sixth status for a mistake DNS had nothing to do with. It is also
|
|
309
|
+
not `Record.normalize_name`, which lowercases a name that came out of a record
|
|
310
|
+
and never rejects.
|
|
311
|
+
|
|
176
312
|
## DNS
|
|
177
313
|
|
|
178
314
|
A resolver is anything that responds to `#call(name)` and returns:
|
|
@@ -239,7 +375,7 @@ zone = {
|
|
|
239
375
|
MailerToGo::SPF.authorize("example.com", resolver: ->(name) { zone.fetch(name, []) })
|
|
240
376
|
```
|
|
241
377
|
|
|
242
|
-
The gem's own suite is built that way:
|
|
378
|
+
The gem's own suite is built that way: 137 examples, zero network access.
|
|
243
379
|
|
|
244
380
|
## Configuration
|
|
245
381
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "set"
|
|
4
4
|
require "mailertogo/spf/record"
|
|
5
|
+
require "mailertogo/spf/term"
|
|
5
6
|
require "mailertogo/spf/result"
|
|
6
7
|
require "mailertogo/spf/sender"
|
|
7
8
|
|
|
@@ -49,12 +50,13 @@ module MailerToGo
|
|
|
49
50
|
# record.
|
|
50
51
|
MAX_DEPTH = 10
|
|
51
52
|
|
|
52
|
-
# Mechanisms that consume one DNS lookup from the budget above
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
53
|
+
# Mechanisms that consume one DNS lookup from the budget above, and the
|
|
54
|
+
# §4.6.2 qualifier map (here only ever read off the record's terminal
|
|
55
|
+
# `all` — see Result#all_qualifier). Both are RFC anatomy and live on
|
|
56
|
+
# Term, which is where a caller goes to ask what a term IS; named here
|
|
57
|
+
# because this is where they are spent.
|
|
58
|
+
QUERYING_MECHANISMS = Term::QUERYING
|
|
59
|
+
ALL_QUALIFIERS = Term::QUALIFIERS
|
|
58
60
|
|
|
59
61
|
# hostname — the domain whose SPF we are reading.
|
|
60
62
|
# sender — a Sender: the names that mean "me".
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "mailertogo/spf/record"
|
|
5
|
+
require "mailertogo/spf/term"
|
|
6
|
+
require "mailertogo/spf/authorization"
|
|
7
|
+
|
|
8
|
+
module MailerToGo
|
|
9
|
+
module SPF
|
|
10
|
+
# Prices a published SPF record against the RFC 7208 §4.6.4 lookup budget,
|
|
11
|
+
# term by term, and notes the chain defects found on the way.
|
|
12
|
+
#
|
|
13
|
+
# Why this is not Authorization. That class answers "does this record
|
|
14
|
+
# authorise ME?", and §4.6.2 ends a receiver's evaluation at the first
|
|
15
|
+
# mechanism that matches — so it deliberately counts only the lookups spent
|
|
16
|
+
# up to the match. That is the right number for a gating decision (a record
|
|
17
|
+
# whose match lands on term 10 of an 11-term record passes at every real
|
|
18
|
+
# receiver, and calling it a permerror would be wrong) and the wrong number
|
|
19
|
+
# for describing the record itself, where the question is what it costs a
|
|
20
|
+
# receiver that has to evaluate ALL of it. That second number is what every
|
|
21
|
+
# SPF checker reports and the one people compare against, and the two can
|
|
22
|
+
# legitimately disagree about the same record — one authorises you at a cost
|
|
23
|
+
# of 10 while itself costing 11 and being broken for everybody past your
|
|
24
|
+
# include.
|
|
25
|
+
#
|
|
26
|
+
# So: walk the whole tree, charge every querying term, and hand back the
|
|
27
|
+
# cost attached to the term that incurred it.
|
|
28
|
+
#
|
|
29
|
+
# Bounded three ways, because this is the sort of thing that ends up behind
|
|
30
|
+
# an unauthenticated "check my domain" box resolving whatever a stranger
|
|
31
|
+
# typed: the §4.6.4 cap itself, a depth limit, and a hard CEILING past which
|
|
32
|
+
# we stop resolving — once a record is over budget the exact number no
|
|
33
|
+
# longer changes what anyone should do about it.
|
|
34
|
+
#
|
|
35
|
+
# Reads DNS through the same injected resolver as everything else, so behind
|
|
36
|
+
# a CachingResolver an audit of a name you have already authorised is
|
|
37
|
+
# largely cache hits.
|
|
38
|
+
class ChainAudit
|
|
39
|
+
# RFC 7208 §4.6.4 — 10 is legal, 11 permerrors. The same cap Authorization
|
|
40
|
+
# enforces; named here because this class reports against it.
|
|
41
|
+
LIMIT = Authorization::MAX_DNS_LOOKUPS
|
|
42
|
+
|
|
43
|
+
# Belt-and-suspenders against a pathological tree, exactly as in the
|
|
44
|
+
# engine: the budget below is the real bound, this stops runaway recursion
|
|
45
|
+
# on a wide shallow record.
|
|
46
|
+
MAX_DEPTH = Authorization::MAX_DEPTH
|
|
47
|
+
|
|
48
|
+
# Stop resolving well past the cap. A record needing 20 lookups and one
|
|
49
|
+
# needing 200 are the same record to a receiver: permerror. Past this the
|
|
50
|
+
# total is reported as a floor (see #capped?) rather than chased.
|
|
51
|
+
CEILING = 2 * LIMIT
|
|
52
|
+
|
|
53
|
+
attr_reader :hostname, :record, :terms, :total, :targets_without_spf, :duplicated_in_chain
|
|
54
|
+
|
|
55
|
+
# record — the record to price. Pass nil to have the apex resolved from
|
|
56
|
+
# DNS at `hostname`, which is the whole question ("what does
|
|
57
|
+
# this domain's SPF cost?") asked in one call.
|
|
58
|
+
# hostname — the name the record is published at. Guards a record that
|
|
59
|
+
# includes itself, and is where a nil record is resolved from.
|
|
60
|
+
# resolver — anything responding to #call(name); see Resolver.
|
|
61
|
+
# term_class— a Term subclass to build the terms as, for a caller that
|
|
62
|
+
# hangs its own copy off a term.
|
|
63
|
+
def self.call(hostname:, resolver:, record: nil, term_class: nil)
|
|
64
|
+
new(hostname: hostname, resolver: resolver, record: record, term_class: term_class).run
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(hostname:, resolver:, record: nil, term_class: nil)
|
|
68
|
+
@record = record&.to_s
|
|
69
|
+
@hostname = Record.normalize_name(hostname)
|
|
70
|
+
@resolver = resolver
|
|
71
|
+
@term_class = term_class || Term
|
|
72
|
+
@spent = 0
|
|
73
|
+
@total = 0
|
|
74
|
+
@terms = []
|
|
75
|
+
@targets_without_spf = []
|
|
76
|
+
@duplicated_in_chain = []
|
|
77
|
+
@resolved = true
|
|
78
|
+
@partial = false
|
|
79
|
+
@capped = false
|
|
80
|
+
# The queried name guards a record that includes itself.
|
|
81
|
+
@seen = Set.new([@hostname])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run
|
|
85
|
+
resolve_apex if @record.nil?
|
|
86
|
+
|
|
87
|
+
parsed = Record.parse_terms(@record, term_class: @term_class)
|
|
88
|
+
# §6.1 — a record containing `all` ignores its redirect= outright,
|
|
89
|
+
# wherever in the record the redirect sits.
|
|
90
|
+
terminal = parsed.any?(&:all?)
|
|
91
|
+
seen_all = false
|
|
92
|
+
|
|
93
|
+
parsed.each do |term|
|
|
94
|
+
unreachable = seen_all || (term.redirect? && terminal)
|
|
95
|
+
# Rebuilt rather than mutated: unreachability is a fact about the
|
|
96
|
+
# term's POSITION, which only this loop knows, and a term is frozen.
|
|
97
|
+
term = @term_class.new(raw: term.raw, position: term.position, unreachable: unreachable)
|
|
98
|
+
cost = unreachable ? 0 : charge(term, depth: 0)
|
|
99
|
+
@terms << term.priced(lookups: cost, running_total: @spent)
|
|
100
|
+
seen_all ||= term.all?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
@total = @spent
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def limit = LIMIT
|
|
108
|
+
|
|
109
|
+
# Is there a record here at all? False when the name publishes no v=spf1
|
|
110
|
+
# record (or DNS did not answer — see #resolved?).
|
|
111
|
+
def published? = !@record.to_s.empty?
|
|
112
|
+
|
|
113
|
+
# Did DNS answer when we resolved the apex ourselves? Always true when the
|
|
114
|
+
# record was handed to us. A resolver hiccup is not "this domain has no
|
|
115
|
+
# SPF", and the two must never be reported as the same thing.
|
|
116
|
+
def resolved? = @resolved
|
|
117
|
+
|
|
118
|
+
# More than §4.6.4 allows: 10 is legal, 11 permerrors.
|
|
119
|
+
def over_limit? = @total.to_i > LIMIT
|
|
120
|
+
|
|
121
|
+
# Part of the chain did not resolve, so the total is a FLOOR. Never report
|
|
122
|
+
# "this fits" from a count we could not finish — the direction that is
|
|
123
|
+
# dangerous to get wrong is the reassuring one.
|
|
124
|
+
def partial? = @partial
|
|
125
|
+
|
|
126
|
+
# We stopped walking at CEILING: the record is far past the cap and the
|
|
127
|
+
# real total is higher than the one reported. Same treatment as partial —
|
|
128
|
+
# the number is a floor — for a different reason.
|
|
129
|
+
def capped? = @capped
|
|
130
|
+
|
|
131
|
+
# How many lookups are still available before the cap, or nil when we
|
|
132
|
+
# cannot say (a floor cannot answer "how much room is left").
|
|
133
|
+
def headroom
|
|
134
|
+
return nil if partial? || capped?
|
|
135
|
+
|
|
136
|
+
[LIMIT - @total.to_i, 0].max
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# No record was supplied: read the apex ourselves.
|
|
142
|
+
def resolve_apex
|
|
143
|
+
txts = @resolver.call(@hostname)
|
|
144
|
+
if txts.nil?
|
|
145
|
+
@resolved = false
|
|
146
|
+
@partial = true
|
|
147
|
+
@record = ""
|
|
148
|
+
return
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
records = Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }
|
|
152
|
+
# §4.5 — two records at the apex permerror the evaluation just as surely
|
|
153
|
+
# as two anywhere else in the chain, so they are noted the same way. We
|
|
154
|
+
# price the first, because there has to be something to read.
|
|
155
|
+
@duplicated_in_chain << @hostname if records.size > 1
|
|
156
|
+
@record = records.first.to_s
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Spend the budget this term costs and return the amount.
|
|
160
|
+
def charge(term, depth:)
|
|
161
|
+
before = @spent
|
|
162
|
+
if term.querying?
|
|
163
|
+
@spent += 1
|
|
164
|
+
# a / mx / ptr / exists cost exactly one query each and expand no
|
|
165
|
+
# further; include: and redirect= pull in another record, and
|
|
166
|
+
# everything in it.
|
|
167
|
+
descend(term.target, depth) if term.include? || term.redirect?
|
|
168
|
+
end
|
|
169
|
+
@spent - before
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def descend(target, depth)
|
|
173
|
+
name = Record.normalize_name(target.to_s)
|
|
174
|
+
return if name.empty?
|
|
175
|
+
# Macros (%{i} etc., §7) are per-message: the lookup is charged, the
|
|
176
|
+
# branch cannot be followed.
|
|
177
|
+
return if name.include?("%")
|
|
178
|
+
return if depth >= MAX_DEPTH
|
|
179
|
+
return unless @seen.add?(name) # loop guard (§11.1)
|
|
180
|
+
|
|
181
|
+
if @spent > CEILING
|
|
182
|
+
@capped = true
|
|
183
|
+
return
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
txts = @resolver.call(name)
|
|
187
|
+
if txts.nil?
|
|
188
|
+
@partial = true
|
|
189
|
+
return
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
records = Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }
|
|
193
|
+
# §5.2 — an include: whose target publishes no SPF record is a permerror
|
|
194
|
+
# for the whole evaluation, not a term that quietly does nothing.
|
|
195
|
+
return @targets_without_spf << name if records.empty?
|
|
196
|
+
# §4.5 — two records anywhere in the chain permerror the whole thing.
|
|
197
|
+
return @duplicated_in_chain << name if records.size > 1
|
|
198
|
+
|
|
199
|
+
walk(records.first, depth + 1)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def walk(record, depth)
|
|
203
|
+
parsed = Record.parse_terms(record, term_class: @term_class)
|
|
204
|
+
terminal = parsed.any?(&:all?)
|
|
205
|
+
|
|
206
|
+
parsed.each do |term|
|
|
207
|
+
break if term.all? # nothing after `all` is ever evaluated
|
|
208
|
+
next if term.redirect? && terminal # §6.1
|
|
209
|
+
|
|
210
|
+
charge(term, depth: depth)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailerToGo
|
|
4
|
+
module SPF
|
|
5
|
+
# Is this untrusted input a name we are willing to resolve?
|
|
6
|
+
#
|
|
7
|
+
# Everything else in this gem takes a hostname it trusts. This is the gate
|
|
8
|
+
# for the case where it did not come from your own database — a box on a
|
|
9
|
+
# page labelled "domain", an API parameter, a CSV somebody uploaded. Asking
|
|
10
|
+
# a resolver to walk an SPF chain is recursive DNS performed on request, so
|
|
11
|
+
# what gets handed to it should be a plausible DNS name and nothing else:
|
|
12
|
+
# anything that could steer the lookup somewhere unintended — a scheme, a
|
|
13
|
+
# port, a path, a query string, an address literal — is stripped or refused
|
|
14
|
+
# here, before any resolver sees it.
|
|
15
|
+
#
|
|
16
|
+
# It is forgiving about SHAPE, because people paste
|
|
17
|
+
# "https://www.example.com/pricing" or "billing@example.com" into a box
|
|
18
|
+
# labelled "domain" and are not wrong to expect that to work, and strict
|
|
19
|
+
# about the RESULT: a syntactically valid hostname, or nil.
|
|
20
|
+
#
|
|
21
|
+
# Distinct from Record.normalize_name, and deliberately so. That one
|
|
22
|
+
# lowercases a name that came out of an SPF record and drops its root dot;
|
|
23
|
+
# it never rejects, because a name inside a record is already as trusted as
|
|
24
|
+
# the record. This one is a gate, and its job is to say no.
|
|
25
|
+
module Hostname
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# RFC 1035 §2.3.4 — 253 octets of presentation form, 63 per label.
|
|
29
|
+
MAX_LENGTH = 253
|
|
30
|
+
|
|
31
|
+
# A DNS label. Underscores are allowed because SPF names are real targets
|
|
32
|
+
# people check — `_spf.google.com` and `_spf.mailertogo.net` are the two
|
|
33
|
+
# most likely things anyone types into such a box after their own domain.
|
|
34
|
+
LABEL = /\A[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\z/
|
|
35
|
+
|
|
36
|
+
# An address literal is not a name to resolve SPF at.
|
|
37
|
+
IPV4 = /\A\d{1,3}(\.\d{1,3}){3}\z/
|
|
38
|
+
|
|
39
|
+
# A TLD is alphabetic, so "1.2" and "v=spf1" never reach a resolver.
|
|
40
|
+
TLD = /\A[a-z]{2,}\z/
|
|
41
|
+
|
|
42
|
+
# The hostname to resolve, or nil when the input is not one.
|
|
43
|
+
def parse(input)
|
|
44
|
+
host = input.to_s.strip.downcase
|
|
45
|
+
return nil if host.empty?
|
|
46
|
+
|
|
47
|
+
host = host.sub(%r{\A[a-z][a-z0-9+.-]*://}, "") # a pasted URL
|
|
48
|
+
host = host.split("@").last.to_s # an email address
|
|
49
|
+
host = host[%r{\A[^/?#]*}].to_s # path, query, fragment
|
|
50
|
+
host = host.split(":").first.to_s # never honour a supplied port
|
|
51
|
+
host = host.delete_prefix("[").delete_suffix("]") # an IPv6 literal in URL form
|
|
52
|
+
host = host.chomp(".") # the root dot is optional
|
|
53
|
+
|
|
54
|
+
return nil if host.empty? || host.length > MAX_LENGTH
|
|
55
|
+
return nil if host.match?(IPV4)
|
|
56
|
+
|
|
57
|
+
# No IDN here: resolvers speak ASCII, and guessing at an encoding for
|
|
58
|
+
# somebody's DNS name is worse than telling them to paste the A-label.
|
|
59
|
+
labels = host.split(".", -1)
|
|
60
|
+
return nil if labels.size < 2
|
|
61
|
+
return nil unless labels.all? { |label| label.match?(LABEL) }
|
|
62
|
+
return nil unless labels.last.match?(TLD)
|
|
63
|
+
|
|
64
|
+
host
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def valid?(input)
|
|
68
|
+
!parse(input).nil?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -43,17 +43,13 @@ module MailerToGo
|
|
|
43
43
|
# standalone instruction rather than guessing — better to under-help than to
|
|
44
44
|
# tell someone to replace a record we could not read.
|
|
45
45
|
class MergePlan
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
#
|
|
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
|
-
|
|
46
|
+
# What a term IS — is it the terminal `all` (with whatever junk is glued
|
|
47
|
+
# onto it), is it a modifier, does it name the sender — is asked of Term
|
|
48
|
+
# rather than re-matched here. There was a second, informal copy of that
|
|
49
|
+
# knowledge in this file; two readings of the same term is exactly how a
|
|
50
|
+
# record ending `~all;google-site-verification=…` ends up merged one way
|
|
51
|
+
# and described another.
|
|
52
|
+
#
|
|
57
53
|
# name — the DNS name the record goes at.
|
|
58
54
|
# record — the standalone record you would otherwise have told them
|
|
59
55
|
# to publish, e.g. Sender#record. It is what a :publish
|
|
@@ -138,26 +134,25 @@ module MailerToGo
|
|
|
138
134
|
(theirs + ours).each do |record|
|
|
139
135
|
seen_all = false
|
|
140
136
|
|
|
141
|
-
Record.
|
|
142
|
-
if
|
|
137
|
+
Record.parse_terms(record).each do |term|
|
|
138
|
+
if term.all?
|
|
143
139
|
seen_all = true
|
|
144
140
|
# First `all` across the ordered records wins — theirs, not ours.
|
|
145
141
|
# A bare `all` is `+all` (§4.6.2); spell it out so the merged line
|
|
146
142
|
# says plainly what it does.
|
|
147
|
-
@all_qualifier ||=
|
|
148
|
-
if
|
|
149
|
-
@notes << "Dropped #{
|
|
143
|
+
@all_qualifier ||= term.qualifier || "+"
|
|
144
|
+
if (junk = term.all_suffix)
|
|
145
|
+
@notes << "Dropped #{junk.inspect}, which was glued onto your #{term.qualifier}all " \
|
|
150
146
|
"and isn't a valid SPF term — publish it as its own TXT record if you still need it."
|
|
151
147
|
end
|
|
152
148
|
next
|
|
153
149
|
end
|
|
154
150
|
|
|
155
|
-
if
|
|
151
|
+
if term.modifier
|
|
156
152
|
# redirect=/exp= are modifiers, not mechanisms: they apply to the
|
|
157
153
|
# whole record wherever they sit, so keep them (deduped by
|
|
158
154
|
# modifier name).
|
|
159
|
-
|
|
160
|
-
modifiers << term unless modifiers.any? { |t| MODIFIER_TERM.match(t)[1].casecmp?(key) }
|
|
155
|
+
modifiers << term unless modifiers.any? { |t| t.modifier == term.modifier }
|
|
161
156
|
next
|
|
162
157
|
end
|
|
163
158
|
|
|
@@ -165,13 +160,14 @@ module MailerToGo
|
|
|
165
160
|
# Unreachable in the published record (nothing after `all` is ever
|
|
166
161
|
# evaluated). Dropping it preserves the record's exact behavior;
|
|
167
162
|
# keeping it would newly authorize a sender receivers ignore today.
|
|
168
|
-
@notes << "Dropped #{term.inspect}, which sat after your #{@all_qualifier}all
|
|
163
|
+
@notes << "Dropped #{term.raw.inspect}, which sat after your #{@all_qualifier}all " \
|
|
164
|
+
"and was never evaluated."
|
|
169
165
|
next
|
|
170
166
|
end
|
|
171
167
|
|
|
172
168
|
next if sender_term?(term)
|
|
173
169
|
|
|
174
|
-
mechanisms << term unless mechanisms.any? { |t| t.casecmp?(term) }
|
|
170
|
+
mechanisms << term.raw unless mechanisms.any? { |t| t.casecmp?(term.raw) }
|
|
175
171
|
end
|
|
176
172
|
end
|
|
177
173
|
|
|
@@ -179,7 +175,7 @@ module MailerToGo
|
|
|
179
175
|
# everything and ends evaluation.
|
|
180
176
|
terms = mechanisms + ["include:#{@include_name}"]
|
|
181
177
|
terms << "#{@all_qualifier}all" if @all_qualifier
|
|
182
|
-
terms += modifiers
|
|
178
|
+
terms += modifiers.map(&:raw)
|
|
183
179
|
"v=spf1 #{terms.join(" ")}"
|
|
184
180
|
end
|
|
185
181
|
|
|
@@ -208,13 +204,16 @@ module MailerToGo
|
|
|
208
204
|
# that reaches the sender through somebody else's include is still THEIR
|
|
209
205
|
# policy record.
|
|
210
206
|
def ours?(record)
|
|
211
|
-
Record.
|
|
207
|
+
Record.parse_terms(record).any? { |term| sender_term?(term) }
|
|
212
208
|
end
|
|
213
209
|
|
|
210
|
+
# Takes a Term, not a string: "does this name us" is a question about the
|
|
211
|
+
# term's target, and Term already knows how to find one.
|
|
214
212
|
def sender_term?(term)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
return false unless term.include? || term.redirect?
|
|
214
|
+
|
|
215
|
+
target = term.target
|
|
216
|
+
!target.nil? && @sender.covers?(target)
|
|
218
217
|
end
|
|
219
218
|
|
|
220
219
|
def same_terms?(a, b)
|
|
@@ -76,9 +76,26 @@ module MailerToGo
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
# The terms of a record, without the leading "v=spf1".
|
|
79
|
+
#
|
|
80
|
+
# Strings, deliberately: this is the raw-text layer, and the engine walks
|
|
81
|
+
# terms in tight loops where a string is exactly what it wants. Ask for
|
|
82
|
+
# .parse_terms when you want to interrogate a term rather than match it.
|
|
79
83
|
def terms(record)
|
|
80
84
|
record.to_s.split(/\s+/).drop(1)
|
|
81
85
|
end
|
|
86
|
+
|
|
87
|
+
# The same terms as Term objects, numbered from 1 in record order, so a
|
|
88
|
+
# caller can ask each one what it is instead of re-deriving that from the
|
|
89
|
+
# string. `term_class:` takes a Term subclass — that is the seam for
|
|
90
|
+
# hanging your own copy off a term without a parallel parser behind it.
|
|
91
|
+
#
|
|
92
|
+
# Term is resolved at call time rather than required at the top of this
|
|
93
|
+
# file: Term is built ON this module, and the raw-text layer should not
|
|
94
|
+
# have to know about the layer above it to hand one back.
|
|
95
|
+
def parse_terms(record, term_class: nil)
|
|
96
|
+
term_class ||= Term
|
|
97
|
+
terms(record).each_with_index.map { |raw, i| term_class.new(raw: raw, position: i + 1) }
|
|
98
|
+
end
|
|
82
99
|
end
|
|
83
100
|
end
|
|
84
101
|
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mailertogo/spf/record"
|
|
4
|
+
|
|
5
|
+
module MailerToGo
|
|
6
|
+
module SPF
|
|
7
|
+
# One term of an SPF record: what it is, and what it costs.
|
|
8
|
+
#
|
|
9
|
+
# `Record` reads the raw text — split a record into terms, strip a
|
|
10
|
+
# qualifier, name a mechanism. This is the same knowledge asked as
|
|
11
|
+
# questions instead of pattern-matched at every call site: `term.include?`,
|
|
12
|
+
# `term.querying?`, `term.unreachable?`. Nothing here re-parses what
|
|
13
|
+
# `Record` already parses; it builds on it, which is the only way two
|
|
14
|
+
# readings of the same term cannot drift apart.
|
|
15
|
+
#
|
|
16
|
+
# Deliberately NOT here: any English sentence about what a term means to a
|
|
17
|
+
# human. A description is product voice — it belongs to whoever is writing
|
|
18
|
+
# to their own customers, in their own words, and no gem should be in the
|
|
19
|
+
# business of writing it. What a consumer wants is a place to hang that
|
|
20
|
+
# copy, so `Term` is designed to be subclassed: every predicate it answers
|
|
21
|
+
# is a predicate your sentence can switch on, and both #priced and
|
|
22
|
+
# `Record.parse_terms(record, term_class:)` build your subclass rather than
|
|
23
|
+
# this class.
|
|
24
|
+
#
|
|
25
|
+
# class AnnotatedTerm < MailerToGo::SPF::Term
|
|
26
|
+
# def meaning
|
|
27
|
+
# return "Everything not matched above is marked, not rejected." if all? && qualifier == "~"
|
|
28
|
+
# …
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Immutable, and frozen on construction: pricing a term builds a copy via
|
|
33
|
+
# #priced rather than mutating it, so a term can never be observed
|
|
34
|
+
# half-priced.
|
|
35
|
+
class Term
|
|
36
|
+
# RFC 7208 §4.6.4 — the mechanisms that cost a DNS query. The lookup
|
|
37
|
+
# budget is a count of these (plus the redirect= modifier).
|
|
38
|
+
QUERYING = %w[include a mx ptr exists].freeze
|
|
39
|
+
|
|
40
|
+
# §4.6.2 — the qualifier a mechanism may carry, and what a match under it
|
|
41
|
+
# means. "+" when absent.
|
|
42
|
+
QUALIFIERS = { "+" => :pass, "-" => :fail, "~" => :softfail, "?" => :neutral }.freeze
|
|
43
|
+
|
|
44
|
+
# Every mechanism SPF defines. Anything else in mechanism position is a
|
|
45
|
+
# syntax error (see #unknown?), not a term that quietly does nothing.
|
|
46
|
+
MECHANISMS = (QUERYING + %w[ip4 ip6 all]).freeze
|
|
47
|
+
|
|
48
|
+
# A modifier (`redirect=`, `exp=`, or an unknown one) rather than a
|
|
49
|
+
# mechanism. Modifiers are name=value and position-independent (§4.6.1).
|
|
50
|
+
MODIFIER = /\A([a-z][a-z0-9\-_.]*)=(.*)\z/i
|
|
51
|
+
|
|
52
|
+
# `all`, with whatever junk is glued onto it. The junk is real and
|
|
53
|
+
# surprisingly common — records ending `~all;google-site-verification=…`
|
|
54
|
+
# exist in the wild, where the `;`-joined fragment is not a valid SPF term
|
|
55
|
+
# at all. Matching it HERE is what keeps such a record's terminal term
|
|
56
|
+
# recognised as an `all` (which ends evaluation, and whose qualifier is
|
|
57
|
+
# the domain's policy) rather than filed as junk and the record read as
|
|
58
|
+
# having no `all` whatsoever.
|
|
59
|
+
ALL = /\Aall([^a-z0-9].*)?\z/i
|
|
60
|
+
|
|
61
|
+
attr_reader :raw, :position, :qualifier, :lookups, :running_total
|
|
62
|
+
|
|
63
|
+
# raw — the term exactly as published.
|
|
64
|
+
# position — 1-based index in the record; 0 when it was not parsed as
|
|
65
|
+
# part of one. For display, and for nothing else.
|
|
66
|
+
# unreachable — the term is never evaluated: it sits after `all`, or it
|
|
67
|
+
# is a redirect= in a record that has an `all`
|
|
68
|
+
# (§5.1/§6.1). Such a term authorises nothing and costs
|
|
69
|
+
# nothing.
|
|
70
|
+
# lookups — DNS lookups this term costs a receiver: itself, plus
|
|
71
|
+
# everything the record it pulls in costs. nil until
|
|
72
|
+
# priced by ChainAudit.
|
|
73
|
+
# running_total — the record's cumulative cost through this term.
|
|
74
|
+
def initialize(raw:, position: 0, unreachable: false, lookups: nil, running_total: nil)
|
|
75
|
+
@raw = raw.to_s
|
|
76
|
+
@position = position
|
|
77
|
+
@unreachable = unreachable
|
|
78
|
+
@lookups = lookups
|
|
79
|
+
@running_total = running_total
|
|
80
|
+
@bare = Record.strip_qualifier(@raw)
|
|
81
|
+
@qualifier = Record.qualifier_of(@raw)
|
|
82
|
+
@modifier_match = MODIFIER.match(@bare)
|
|
83
|
+
@all_match = ALL.match(@bare)
|
|
84
|
+
@mechanism = @modifier_match ? nil : Record.mechanism_of(@bare)
|
|
85
|
+
freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A copy carrying the cost ChainAudit measured. `self.class` so a
|
|
89
|
+
# subclass that adds its own copy stays that subclass through pricing.
|
|
90
|
+
def priced(lookups:, running_total:)
|
|
91
|
+
self.class.new(raw: raw, position: position, unreachable: unreachable?,
|
|
92
|
+
lookups: lookups, running_total: running_total)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ── What it is ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
# "include", "ip4", "all"… nil for a modifier or for junk.
|
|
98
|
+
def mechanism
|
|
99
|
+
return "all" if @all_match
|
|
100
|
+
|
|
101
|
+
@mechanism if MECHANISMS.include?(@mechanism)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# "redirect", "exp", or another modifier name; nil for a mechanism.
|
|
105
|
+
def modifier
|
|
106
|
+
@modifier_match && @modifier_match[1].downcase
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The value after the ":" or "=" — an include target, an IP range, an
|
|
110
|
+
# explanation name. nil for a bare `a`, `mx` or `all`.
|
|
111
|
+
def target
|
|
112
|
+
value = @modifier_match ? @modifier_match[2] : @bare.split(":", 2)[1]
|
|
113
|
+
value.to_s.empty? ? nil : value
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# :pass | :fail | :softfail | :neutral — what a match under this
|
|
117
|
+
# qualifier means to a receiver (§4.6.2). On the terminal `all` this is
|
|
118
|
+
# the domain's whole policy for unauthorised mail (§5.1).
|
|
119
|
+
def qualifier_meaning = QUALIFIERS[@qualifier || "+"]
|
|
120
|
+
|
|
121
|
+
def all? = mechanism == "all"
|
|
122
|
+
def include? = mechanism == "include"
|
|
123
|
+
def redirect? = modifier == "redirect"
|
|
124
|
+
def ip? = %w[ip4 ip6].include?(mechanism)
|
|
125
|
+
|
|
126
|
+
# Does this term spend from the §4.6.4 budget at all?
|
|
127
|
+
def querying? = QUERYING.include?(mechanism) || redirect?
|
|
128
|
+
|
|
129
|
+
# Never evaluated by a receiver, so it authorises nothing and costs
|
|
130
|
+
# nothing. Set by whoever read the record in order (ChainAudit); a term
|
|
131
|
+
# on its own cannot know what precedes it.
|
|
132
|
+
def unreachable? = @unreachable == true
|
|
133
|
+
|
|
134
|
+
# Not a mechanism SPF defines and not a modifier: junk. Per §4.6 a syntax
|
|
135
|
+
# error in a record permits a receiver to permerror the whole thing, so
|
|
136
|
+
# this is never harmless.
|
|
137
|
+
def unknown? = mechanism.nil? && modifier.nil?
|
|
138
|
+
|
|
139
|
+
# Which of the three a term is: a named mechanism, "modifier", "unknown".
|
|
140
|
+
def kind
|
|
141
|
+
return "modifier" if modifier
|
|
142
|
+
return "unknown" if unknown?
|
|
143
|
+
|
|
144
|
+
mechanism
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The junk glued onto an `all` (`~all;google-site-verification=…`), or
|
|
148
|
+
# nil. Worth surfacing: it is inside the record rather than beside it, so
|
|
149
|
+
# it is not the second TXT record its author thought they were writing.
|
|
150
|
+
def all_suffix
|
|
151
|
+
suffix = @all_match && @all_match[1].to_s.strip
|
|
152
|
+
suffix.to_s.empty? ? nil : suffix
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def to_s = raw
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/mailertogo/spf.rb
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "mailertogo/spf/version"
|
|
4
4
|
require "mailertogo/spf/record"
|
|
5
|
+
require "mailertogo/spf/term"
|
|
6
|
+
require "mailertogo/spf/hostname"
|
|
5
7
|
require "mailertogo/spf/sender"
|
|
6
8
|
require "mailertogo/spf/resolver"
|
|
7
9
|
require "mailertogo/spf/result"
|
|
8
10
|
require "mailertogo/spf/authorization"
|
|
11
|
+
require "mailertogo/spf/chain_audit"
|
|
9
12
|
require "mailertogo/spf/plan"
|
|
10
13
|
require "mailertogo/spf/merge_plan"
|
|
11
14
|
|
|
@@ -14,7 +17,7 @@ module MailerToGo
|
|
|
14
17
|
# the include chain, stops where the receiver stops, and counts DNS lookups
|
|
15
18
|
# against the RFC 7208 §4.6.4 cap.
|
|
16
19
|
#
|
|
17
|
-
#
|
|
20
|
+
# Three questions, three entry points:
|
|
18
21
|
#
|
|
19
22
|
# MailerToGo::SPF.authorize("example.com")
|
|
20
23
|
# → does this domain's published SPF authorize me?
|
|
@@ -22,6 +25,9 @@ module MailerToGo
|
|
|
22
25
|
# MailerToGo::SPF.merge_plan("example.com")
|
|
23
26
|
# → what should I tell them to publish, given what is already there?
|
|
24
27
|
#
|
|
28
|
+
# MailerToGo::SPF.chain_audit("example.com")
|
|
29
|
+
# → what does this record cost a receiver, term by term?
|
|
30
|
+
#
|
|
25
31
|
# Both take `include:` (the mechanism you want authorized) and `aliases:`
|
|
26
32
|
# (other names that mean the same sender). Both default to MailerToGo's own
|
|
27
33
|
# names, so the zero-argument form is the useful one for MailerToGo customers
|
|
@@ -106,6 +112,30 @@ module MailerToGo
|
|
|
106
112
|
)
|
|
107
113
|
end
|
|
108
114
|
|
|
115
|
+
# What does this record cost a receiver that evaluates all of it?
|
|
116
|
+
# Returns a ChainAudit (see chain_audit.rb).
|
|
117
|
+
#
|
|
118
|
+
# This is a different number from Result#lookups and is meant to be: the
|
|
119
|
+
# authorization walk stops where the receiver stops (§4.6.2), while this
|
|
120
|
+
# prices the whole tree against the §4.6.4 budget — the number other SPF
|
|
121
|
+
# checkers report. Notably sender-agnostic; there is no `include:` here,
|
|
122
|
+
# because the record's cost has nothing to do with who is asking.
|
|
123
|
+
#
|
|
124
|
+
# record: price this record instead of resolving one at `hostname`, which
|
|
125
|
+
# is how you price a record that is not published yet.
|
|
126
|
+
def chain_audit(hostname, record: nil, resolver: nil, term_class: nil)
|
|
127
|
+
ChainAudit.call(hostname: hostname, record: record, resolver: resolver || self.resolver,
|
|
128
|
+
term_class: term_class)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Is this untrusted input something we should resolve at all? Returns the
|
|
132
|
+
# normalized hostname, or nil. See hostname.rb — it is a gate, kept
|
|
133
|
+
# separate from the entry points above on purpose, because "that is not a
|
|
134
|
+
# hostname" is a fact about the INPUT and must not be dressed up as a fact
|
|
135
|
+
# about somebody's DNS.
|
|
136
|
+
def normalize_hostname(input) = Hostname.parse(input)
|
|
137
|
+
def hostname?(input) = Hostname.valid?(input)
|
|
138
|
+
|
|
109
139
|
# The names that mean "me". Public because a caller that asks both
|
|
110
140
|
# questions about the same sender should build it once.
|
|
111
141
|
def sender(include: nil, aliases: nil)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mailertogo-spf
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- MailerToGo
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
An SPF engine that follows include:/redirect= chains, stops at the first
|
|
@@ -16,8 +16,10 @@ description: |
|
|
|
16
16
|
cap of 10 — so it agrees with what real receivers do instead of
|
|
17
17
|
string-matching a token. It also plans the record a domain should publish:
|
|
18
18
|
given what is already at the name, merge one include into the existing
|
|
19
|
-
record rather than adding a second v=spf1 record beside it.
|
|
20
|
-
|
|
19
|
+
record rather than adding a second v=spf1 record beside it. And it prices a
|
|
20
|
+
record: what the whole tree costs a receiver that evaluates all of it, term
|
|
21
|
+
by term, against the same cap. No Rails, no runtime dependencies, injectable
|
|
22
|
+
DNS resolver.
|
|
21
23
|
email:
|
|
22
24
|
- support@mailertogo.com
|
|
23
25
|
executables: []
|
|
@@ -29,12 +31,15 @@ files:
|
|
|
29
31
|
- README.md
|
|
30
32
|
- lib/mailertogo/spf.rb
|
|
31
33
|
- lib/mailertogo/spf/authorization.rb
|
|
34
|
+
- lib/mailertogo/spf/chain_audit.rb
|
|
35
|
+
- lib/mailertogo/spf/hostname.rb
|
|
32
36
|
- lib/mailertogo/spf/merge_plan.rb
|
|
33
37
|
- lib/mailertogo/spf/plan.rb
|
|
34
38
|
- lib/mailertogo/spf/record.rb
|
|
35
39
|
- lib/mailertogo/spf/resolver.rb
|
|
36
40
|
- lib/mailertogo/spf/result.rb
|
|
37
41
|
- lib/mailertogo/spf/sender.rb
|
|
42
|
+
- lib/mailertogo/spf/term.rb
|
|
38
43
|
- lib/mailertogo/spf/version.rb
|
|
39
44
|
homepage: https://github.com/aluminumio/mailertogo-spf
|
|
40
45
|
licenses:
|