Project

mta_sts

0.0
The project is in a healthy, maintained state
Answers one question before delivering a message: must this connection be encrypted, and to which servers?
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 1.74
~> 0.2
 Project Readme

mta_sts

🔒 SMTP MTA Strict Transport Security for Ruby

Answers one question before a message goes out: must this connection be encrypted, and to which servers?

Resolve a recipient domain's MTA-STS policy (RFC 8461) and find out which of its mail servers may be used and whether TLS is optional. Also discovers where the domain wants TLS failure reports sent (RFC 8460 TLSRPT) — discovery only; generating and submitting reports is yours.

Installation

Add this line to your application's Gemfile:

gem "mta_sts"

And run:

bundle install

Look up your first policy with:

require "mta_sts"

result = MtaSts.lookup("gmail.com", known: nil)
result.enforce? # => true

How It Works

Looking Up a Policy

result = MtaSts.lookup(domain, known:, resolver:, fetcher:, now:)
# => #<MtaSts::Result status:, comment:, policy:>
  • domain - the recipient domain, the part after the @
  • known - the policy this library returned last time, or nil. There's no cache here, so this is how a policy survives between calls. Required on purpose: known: nil should be a decision, not an omission
  • resolver - defaults to MtaSts::Resolver.new
  • fetcher - defaults to MtaSts::Fetcher.new
  • now - a Time, for expiry; defaults to Time.now
result = MtaSts.lookup("gmail.com", known: store["gmail.com"])
store["gmail.com"] = result.policy if result.policy

if result.enforce? && !result.allows?(mx_host)
  # the policy names which servers are legitimate, and this is not one of them
end

Hand back what you kept, keep what comes back - both halves matter.

Results

result.status              # => "found"
result.policy.mode         # => :enforce
result.policy.mx           # => ["gmail-smtp-in.l.google.com", "*.gmail-smtp-in.l.google.com"]
result.policy.max_age      # => 86400
result.policy.id           # => "20190429T010101"
result.policy.domain       # => "gmail.com"
result.policy.expires_at   # => 2026-07-29 14:03:11 UTC

result.enforce?                                    # => true
result.allows?("alt1.gmail-smtp-in.l.google.com")  # => true
result.allows?("mx.attacker.example")              # => false

Two questions get asked at connect time: must TLS be verified and non-optional (enforce?), and is this hostname one the domain vouches for (allows?).

Status and Policy Are Separate Axes

status describes what this lookup learned. policy describes what you must honor. Nothing raises - a result always comes back with a comment:

result.status   # => "temperror"
result.comment  # => "DNS didn't answer for _mta-sts.example.com"

Four statuses, each with a predicate:

  • found / found? - a policy was fetched, or renewed against an unchanged id
  • none / no_record? - no _mta-sts record seen
  • temperror / temperror? - DNS or the policy host didn't answer
  • permerror / permerror? - the record or policy file is broken

policy is present whenever there's one to honor: fresh, renewed, or the known one you passed in that hasn't expired.

what happened status policy enforce?
fresh fetch, mode: enforce found present true
DNS timeout, held an enforcing policy temperror held true
TXT record gone, held an enforcing policy none held true
never had one, no TXT record none nil false

The third row looks wrong and isn't. §5.1 keeps a policy in force for its max_age regardless of what DNS says now - deleting the TXT record does not withdraw enforcement, only serving mode: none does.

Use enforce? and allows? for the connection decision, not status:

if result.enforce? && !result.allows?(mx)
  defer_or_fail
elsif result.temperror? && !result.policy
  defer # we don't know; don't downgrade
end

temperror is not none. A resolver timeout means "we could not ask"; reading it as "no policy published" hands an attacker a downgrade for the cost of dropping a UDP packet.

Holding On to a Policy

There's no cache in this library. lookup takes the policy you kept last time and hands one back.

result = MtaSts.lookup("example.com", known: store["example.com"])
store["example.com"] = result.policy if result.policy

Skip that round trip and §5.1 doesn't apply. Three rules govern what comes back:

  • A matching id renews the expiry without refetching
  • A temporary failure keeps a policy that hasn't expired
  • A fetched mode: none replaces it immediately

A policy also carries the domain it was fetched for. Handing back another domain's policy under the wrong key is ignored rather than enforced. Store that too when you persist:

row = { body: policy.to_s, id: policy.id, domain: policy.domain, fetched_at: policy.fetched_at }
MtaSts::Policy.parse(row[:body], id: row[:id], domain: row[:domain], fetched_at: row[:fetched_at])

TLSRPT Record Discovery

Where a domain wants TLS failure reports sent (RFC 8460). One TXT lookup at _smtp._tls.<domain> — not report generation or submission.

result = MtaSts.reporting("gmail.com")
# => #<MtaSts::Report::Result status:, comment:, report:>

result.status   # => "found"
result.rua      # => ["mailto:tlsrpt@smtp.gmail.com"]
result.report.mailto
result.report.https
result.report.ignored  # destinations that were named but are neither mailto: nor https:

Same status vocabulary as policy lookup (found / none / temperror / permerror), with no_record? for "none". Unknown TXT fields on the name are ignored; only mailto: and https: destinations are kept.

Custom Resolver & Fetcher

MtaSts.lookup("example.com", known: nil,
  resolver: MyCachingResolver.new,  # DNS, for discovery
  fetcher:  MyPolicyFetcher.new)    # the HTTPS GET
  • resolver - needs one method, txt, over dnsruby by default. Covers discovery only (_mta-sts and _smtp._tls). Raise NotFound for no such record, Timeout or ServerFailure for no answer. The policy host itself is resolved by the HTTP stack inside the fetcher.
  • fetcher - one GET, over stdlib Net::HTTP by default, certificate verification on. Body size and a total wall-clock budget are capped so a hostile host cannot hold the thread forever.

Publishing a Policy

policy = MtaSts::Policy.new(mode: :enforce, mx: ["mx.example.com"], max_age: 604800)
puts policy.to_s
version: STSv1
mode: enforce
mx: mx.example.com
max_age: 604800

And read one back:

MtaSts::Policy.parse(File.read("mta-sts.txt"))
# => #<MtaSts::Policy mode: :enforce, mx: ["mx.example.com"], max_age: 604800>

Command Line

mta-sts gmail.com alt1.gmail-smtp-in.l.google.com
domain:  gmail.com
status:  found
comment: fetched policy id 20190429T010101 from mta-sts.gmail.com
mode:    enforce
id:      20190429T010101
max_age: 86400
expires: 2026-07-29 15:51:41 UTC
mx:
  smtp.google.com
  gmail-smtp-in.l.google.com
  *.gmail-smtp-in.l.google.com
rua:
  mailto:tlsrpt@smtp.gmail.com

alt1.gmail-smtp-in.l.google.com: accepted

Exit codes answer the MTA-STS question only. TLSRPT is printed for diagnostics and never changes the exit:

  • 0 - answered, a policy vouches for the MX, or the domain publishes none
  • 1 - refused, a policy applies and does not name it
  • 2 - unknown, DNS or the policy host didn't answer, or what it served was broken
  • 64 - usage

What It Is Strict About

  • HTTP 3xx are never followed
  • HTTP caching (RFC 7234) is never used - freshness comes from the TXT id and max_age only
  • Only 200 is a policy
  • The certificate is verified against mta-sts.<domain>, the policy host name
  • The _mta-sts discovery record must match the ABNF (v=STSv1; case-sensitive, a single well-formed id)
  • Fetch body size and total time are capped

What It Is Not That Strict

  • Content-Type: text/plain is checked but not enforced - §3.2 says SHOULD, not MUST, and real policies in the wild omit it
  • Line terminators may be bare LF as well as CRLF

What It Does Not Check

  • Certificate revocation. Net::HTTP doesn't check it, and this library doesn't wire up OCSP or CRL. Pass your own fetcher if you need it.
  • The address the policy host resolves to. Fetched at whatever address DNS returns. The certificate check is what protects you here.
  • DANE. A separate mechanism needing DNSSEC validation and the peer certificate mid-handshake, which this library never sees.
  • TLSRPT report generation or submission. This library finds the rua; building and sending aggregate reports is the application's job.
  • Anything about the SMTP connection. This library opens one socket, to fetch a policy over HTTPS. It never speaks SMTP.

Testing

bundle install
rake

Nothing leaves the machine. Every lookup takes a resolver: and a fetcher:, and the test doubles stand a hash in for DNS and a string in for the policy host.

History

View the changelog

Contributing

Everyone is encouraged to help improve this project. Here are a few ways you can help:

To get started with development:

git clone https://github.com/mailpiece/mta_sts.git
cd mta_sts
bundle install
bundle exec rakecl

License

MIT. See LICENSE.