Class: MailerToGo::SPF::Authorization
- Inherits:
-
Object
- Object
- MailerToGo::SPF::Authorization
- Defined in:
- lib/mailertogo/spf/authorization.rb
Overview
Answers one question the way a receiving MTA would: does
Why resolve at all, instead of string-matching the record? Because SPF is
a TREE, not a string. The obvious implementation — "does the apex record
contain the literal token include:_spf.mailertogo.net" — reports a false
failure against every customer who publishes an outer alias:
example.com TXT v=spf1 include:spf.hosting.example include:mailertogo.net ~all
mailertogo.net TXT v=spf1 include:_spf.mailertogo.net ~all
_spf.mailertogo.net TXT v=spf1 ip4:… ip4:… ~all
That record passes SPF at every real receiver. A literal match calls it a
failure, and anything gated on the answer — verification state, drift
alerts, the ability to send — goes wrong with it. So we RESOLVE the chain:
follow include: (and the redirect= modifier) until we reach a name the
sender owns, or an ip4/ip6 mechanism covering the sender's addresses.
RFC 7208 §4.6.4 caps an evaluation at 10 DNS-querying mechanisms; past that a receiver returns PERMERROR and the record does NOT pass. We enforce the same cap rather than silently passing a record real receivers reject — and it doubles as the bound that stops a hostile record walking us into an unbounded crawl.
The cap counts the terms an evaluation actually EVALUATES, and §4.6.2 ends the evaluation at the first mechanism that MATCHES — so we walk terms in record order and stop at the match, exactly where a receiver stops. Counting the whole tree instead reports a false permerror for the very common record whose match lands on term 10 of an 11-term record: inside budget, and passing at every real receiver.
Constant Summary collapse
- MAX_DNS_LOOKUPS =
RFC 7208 §4.6.4 — mechanisms that cost a DNS query (include/a/mx/ptr/ exists) plus the redirect modifier, summed over the terms EVALUATED before the match. "MUST limit ... to 10" makes exactly 10 legal: 10 passes, 11 permerrors.
10- MAX_DEPTH =
Belt-and-suspenders against a pathological tree; the lookup budget above is the real bound, this just stops runaway recursion on a wide shallow record.
10- QUERYING_MECHANISMS =
Mechanisms that consume one DNS lookup from the budget above.
%w[include a mx ptr exists].freeze
- ALL_QUALIFIERS =
RFC 7208 §4.6.2 — the qualifier on a mechanism, here only ever read off the record's terminal
all. See Result#all_qualifier. { "+" => :pass, "-" => :fail, "~" => :softfail, "?" => :neutral }.freeze
Class Method Summary collapse
-
.call(hostname, sender:, resolver:, published: nil, logger: nil) ⇒ Object
hostname — the domain whose SPF we are reading.
-
.sending_nets(sender, resolver) ⇒ Object
The sending addresses a sender authorizes, read from its own leaf record — the same record
include:_spf.mailertogo.netresolves to.
Instance Method Summary collapse
-
#initialize(hostname, sender:, resolver:, published: nil, logger: nil) ⇒ Authorization
constructor
A new instance of Authorization.
- #run ⇒ Object
Constructor Details
#initialize(hostname, sender:, resolver:, published: nil, logger: nil) ⇒ Authorization
Returns a new instance of Authorization.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/mailertogo/spf/authorization.rb', line 85 def initialize(hostname, sender:, resolver:, published: nil, logger: nil) @host = Record.normalize_name(hostname) @sender = sender @lookup = resolver @logger = logger @published = published.to_s.empty? ? nil : published.to_s @want = sender.include_name @seen = Set.new([@host]) @lookups = 0 @dns_error = false @permerror = nil @matched = nil @matched_directly = false @match_at = nil # [IPAddr, lookups spent before that term] — the second half is what # lets us charge a pinned record only the lookups a receiver does # before it stops. @customer_nets = [] @ip_pinned = nil @ip_coverage = nil @ip_match_at = nil @all_qualifier = nil end |
Class Method Details
.call(hostname, sender:, resolver:, published: nil, logger: nil) ⇒ Object
hostname — the domain whose SPF we are reading. sender — a Sender: the names that mean "me". resolver — anything responding to #call(name); see Resolver. published — evaluate this record AS IF it were published at hostname, instead of whatever DNS says is there. The only way to ask "what would this cost a receiver?" of a line that is not published yet; MergePlan prices its merged record with it. Everything below the apex still resolves from real DNS. logger — optional, anything responding to #warn.
68 69 70 |
# File 'lib/mailertogo/spf/authorization.rb', line 68 def self.call(hostname, sender:, resolver:, published: nil, logger: nil) new(hostname, sender: sender, resolver: resolver, published: published, logger: logger).run end |
.sending_nets(sender, resolver) ⇒ Object
The sending addresses a sender authorizes, read from its own leaf record
— the same record include:_spf.mailertogo.net resolves to. Used to
DETECT a customer who hardcoded those addresses (:pinned), not to bless
it. Resolved separately from the customer's walk so it never costs them
lookup budget.
77 78 79 80 81 82 83 |
# File 'lib/mailertogo/spf/authorization.rb', line 77 def self.sending_nets(sender, resolver) txts = resolver.call(sender.include_name) record = Array(txts).map { |t| Record.normalize_txt(t) }.find { |t| Record.spf_record?(t) } return [] if record.nil? || record.empty? Record.ip_nets(Record.terms(record)) end |
Instance Method Details
#run ⇒ Object
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/mailertogo/spf/authorization.rb', line 109 def run records, err = apex_records return unknown("DNS lookup for #{@host} failed") if err == :error if records.size > 1 # RFC 7208 §4.5: more than one v=spf1 record is a PERMERROR — no # evaluation happens at all. We still scan every one of them for the # sender's include so the result can say "broken, but my record IS # published" (see #matched). records.each { |r| note_sender_names(Record.terms(r), 0) } return permerror(:duplicate_records, "#{@host} publishes more than one v=spf1 record — receivers " \ "return permerror (RFC 7208 §4.5) and no SPF passes for this domain") end record = records.first return failure("No v=spf1 record on #{@host}") if record.nil? walk(record, depth: 0, authoritative: true) match_by_ip! if @match_at.nil? # Over budget beats everything: receivers PERMERROR such a record, so it # does not pass even when the sender's include is sitting right there in # it. What counts is the budget spent up to the MATCH (see #budget_used), # not the size of the tree — terms after the match are never evaluated # by anyone. if budget_used > MAX_DNS_LOOKUPS return permerror(:lookup_limit, "#{@host}'s SPF needs more than #{MAX_DNS_LOOKUPS} DNS lookups " \ "(RFC 7208 §4.6.4) — receivers return permerror and the record never passes") end return permerror(*@permerror) if @permerror return pass if @match_at return pinned if @ip_pinned return unknown("SPF chain for #{@host} could not be fully resolved (DNS lookup failed)") if @dns_error failure("#{@host}'s SPF does not authorize #{@want} — resolved the full include chain " \ "(#{pluralize(@lookups, "DNS lookup")}) and #{@want} is not in it#{all_clause}") end |