Class: MailerToGo::SPF::Authorization

Inherits:
Object
  • Object
show all
Defined in:
lib/mailertogo/spf/authorization.rb

Overview

Answers one question the way a receiving MTA would: does 's published SPF actually authorize this sender?

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

Instance Method Summary collapse

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

#runObject



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