1// Package mtasts implements MTA-STS (SMTP MTA Strict Transport Security, RFC 8461)
2// which allows a domain to specify SMTP TLS requirements.
4// SMTP for message delivery to a remote mail server always starts out unencrypted,
5// in plain text. STARTTLS allows upgrading the connection to TLS, but is optional
6// and by default mail servers will fall back to plain text communication if
7// STARTTLS does not work (which can be sabotaged by DNS manipulation or SMTP
8// connection manipulation). MTA-STS can specify a policy for requiring STARTTLS to
9// be used for message delivery. A TXT DNS record at "_mta-sts.<domain>" specifies
10// the version of the policy, and
11// "https://mta-sts.<domain>/.well-known/mta-sts.txt" serves the policy.
23 "github.com/prometheus/client_golang/prometheus"
24 "github.com/prometheus/client_golang/prometheus/promauto"
26 "github.com/mjl-/mox/dns"
27 "github.com/mjl-/mox/metrics"
28 "github.com/mjl-/mox/mlog"
29 "github.com/mjl-/mox/moxio"
32var xlog = mlog.New("mtasts")
35 metricGet = promauto.NewHistogramVec(
36 prometheus.HistogramOpts{
37 Name: "mox_mtasts_get_duration_seconds",
38 Help: "MTA-STS get of policy, including lookup, duration and result.",
39 Buckets: []float64{0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20},
42 "result", // ok, lookuperror, fetcherror
47// Pair is an extension key/value pair in a MTA-STS DNS record or policy.
53// Record is an MTA-STS DNS record, served under "_mta-sts.<domain>" as a TXT
58// v=STSv1; id=20160831085700Z
60 Version string // "STSv1", for "v=". Required.
61 ID string // Record version, for "id=". Required.
62 Extensions []Pair // Optional extensions.
65// String returns a textual version of the MTA-STS record for use as DNS TXT
67func (r Record) String() string {
68 b := &strings.Builder{}
69 fmt.Fprint(b, "v="+r.Version)
70 fmt.Fprint(b, "; id="+r.ID)
71 for _, p := range r.Extensions {
72 fmt.Fprint(b, "; "+p.Key+"="+p.Value)
77// Mode indicates how the policy should be interpreted.
83 ModeEnforce Mode = "enforce" // Policy must be followed, i.e. deliveries must fail if a TLS connection cannot be made.
84 ModeTesting Mode = "testing" // In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLS-RPT.
85 ModeNone Mode = "none" // In case MTA-STS is not or no longer implemented.
88// STSMX is an allowlisted MX host name/pattern.
89// todo: find a way to name this just STSMX without getting duplicate names for "MX" in the sherpa api.
91 // "*." wildcard, e.g. if a subdomain matches. A wildcard must match exactly one
92 // label. *.example.com matches mail.example.com, but not example.com, and not
93 // foor.bar.example.com.
99// LogString returns a loggable string representing the host, with both unicode
100// and ascii version for IDNA domains.
101func (s STSMX) LogString() string {
106 if s.Domain.Unicode == "" {
107 return pre + s.Domain.ASCII
109 return pre + s.Domain.Unicode + "/" + pre + s.Domain.ASCII
112// Policy is an MTA-STS policy as served at "https://mta-sts.<domain>/.well-known/mta-sts.txt".
114 Version string // "STSv1"
117 MaxAgeSeconds int // How long this policy can be cached. Suggested values are in weeks or more.
121// String returns a textual representation for serving at the well-known URL.
122func (p Policy) String() string {
123 b := &strings.Builder{}
124 line := func(k, v string) {
125 fmt.Fprint(b, k+": "+v+"\n")
127 line("version", p.Version)
128 line("mode", string(p.Mode))
129 line("max_age", fmt.Sprintf("%d", p.MaxAgeSeconds))
130 for _, mx := range p.MX {
131 s := mx.Domain.Name()
140// Matches returns whether the hostname matches the mx list in the policy.
141func (p *Policy) Matches(host dns.Domain) bool {
143 for _, mx := range p.MX {
145 v := strings.SplitN(host.ASCII, ".", 2)
146 if len(v) == 2 && v[1] == mx.Domain.ASCII {
149 } else if host == mx.Domain {
158 ErrNoRecord = errors.New("mtasts: no mta-sts dns txt record") // Domain does not implement MTA-STS. If a cached non-expired policy is available, it should still be used.
159 ErrMultipleRecords = errors.New("mtasts: multiple mta-sts records") // Should be treated as if domain does not implement MTA-STS, unless a cached non-expired policy is available.
160 ErrDNS = errors.New("mtasts: dns lookup") // For temporary DNS errors.
161 ErrRecordSyntax = errors.New("mtasts: record syntax error")
164// LookupRecord looks up the MTA-STS TXT DNS record at "_mta-sts.<domain>",
165// following CNAME records, and returns the parsed MTA-STS record, the DNS TXT
166// record and any CNAMEs that were followed.
167func LookupRecord(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (rrecord *Record, rtxt string, rcnames []string, rerr error) {
168 log := xlog.WithContext(ctx)
171 log.Debugx("mtasts lookup result", rerr, mlog.Field("domain", domain), mlog.Field("record", rrecord), mlog.Field("cnames", rcnames), mlog.Field("duration", time.Since(start)))
176 // We lookup the txt record, but must follow CNAME records when the TXT does not exist.
178 name := "_mta-sts." + domain.ASCII + "."
182 txts, err = dns.WithPackage(resolver, "mtasts").LookupTXT(ctx, name)
183 if dns.IsNotFound(err) {
184 // DNS has no specified limit on how many CNAMEs to follow. Chains of 10 CNAMEs
185 // have been seen on the internet.
186 if len(cnames) > 16 {
187 return nil, "", cnames, fmt.Errorf("too many cnames")
189 cname, err := dns.WithPackage(resolver, "mtasts").LookupCNAME(ctx, name)
190 if dns.IsNotFound(err) {
191 return nil, "", cnames, ErrNoRecord
194 return nil, "", cnames, fmt.Errorf("%w: %s", ErrDNS, err)
196 cnames = append(cnames, cname)
199 } else if err != nil {
200 return nil, "", cnames, fmt.Errorf("%w: %s", ErrDNS, err)
208 for _, txt := range txts {
209 r, ismtasts, err := ParseRecord(txt)
212 // "v=STSv1 ;" (note the space) as a non-STS record too in case of multiple TXT
213 // records. We treat it as an STS record that is invalid, which is possibly more
218 return nil, "", cnames, err
221 return nil, "", cnames, ErrMultipleRecords
227 return nil, "", cnames, ErrNoRecord
229 return record, text, cnames, nil
232// Policy fetch errors.
234 ErrNoPolicy = errors.New("mtasts: no policy served") // If the name "mta-sts.<domain>" does not exist in DNS or if webserver returns HTTP status 404 "File not found".
235 ErrPolicyFetch = errors.New("mtasts: cannot fetch policy") // E.g. for HTTP request errors.
236 ErrPolicySyntax = errors.New("mtasts: policy syntax error")
239// HTTPClient is used by FetchPolicy for HTTP requests.
240var HTTPClient = &http.Client{
241 CheckRedirect: func(req *http.Request, via []*http.Request) error {
246// FetchPolicy fetches a new policy for the domain, at
247// https://mta-sts.<domain>/.well-known/mta-sts.txt.
249// FetchPolicy returns the parsed policy and the literal policy text as fetched
250// from the server. If a policy was fetched but could not be parsed, the policyText
251// return value will be set.
253// Policies longer than 64KB result in a syntax error.
255// If an error is returned, callers should back off for 5 minutes until the next
257func FetchPolicy(ctx context.Context, domain dns.Domain) (policy *Policy, policyText string, rerr error) {
258 log := xlog.WithContext(ctx)
261 log.Debugx("mtasts fetch policy result", rerr, mlog.Field("domain", domain), mlog.Field("policy", policy), mlog.Field("policytext", policyText), mlog.Field("duration", time.Since(start)))
265 ctx, cancel := context.WithTimeout(ctx, time.Minute)
268 // TLS requirements are what the Go standard library checks: trusted, non-expired,
270 url := "https://mta-sts." + domain.Name() + "/.well-known/mta-sts.txt"
271 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
273 return nil, "", fmt.Errorf("%w: http request: %s", ErrPolicyFetch, err)
275 // We are not likely to reuse a connection: we cache policies and negative DNS
276 // responses. So don't keep connections open unnecessarily.
279 resp, err := HTTPClient.Do(req)
280 if dns.IsNotFound(err) {
281 return nil, "", ErrNoPolicy
284 return nil, "", fmt.Errorf("%w: http get: %s", ErrPolicyFetch, err)
286 metrics.HTTPClientObserve(ctx, "mtasts", req.Method, resp.StatusCode, err, start)
287 defer resp.Body.Close()
288 if resp.StatusCode == http.StatusNotFound {
289 return nil, "", ErrNoPolicy
291 if resp.StatusCode != http.StatusOK {
293 return nil, "", fmt.Errorf("%w: http status %s while status 200 is required", ErrPolicyFetch, resp.Status)
296 // We don't look at Content-Type and charset. It should be ASCII or UTF-8, we'll
300 buf, err := io.ReadAll(&moxio.LimitReader{R: resp.Body, Limit: 64 * 1024})
302 return nil, "", fmt.Errorf("%w: reading policy: %s", ErrPolicySyntax, err)
304 policyText = string(buf)
305 policy, err = ParsePolicy(policyText)
307 return nil, policyText, fmt.Errorf("parsing policy: %w", err)
309 return policy, policyText, nil
312// Get looks up the MTA-STS DNS record and fetches the policy.
314// Errors can be those returned by LookupRecord and FetchPolicy.
316// If a valid policy cannot be retrieved, a sender must treat the domain as not
317// implementing MTA-STS. If a sender has a non-expired cached policy, that policy
320// If a record was retrieved, but a policy could not be retrieved/parsed, the
321// record is still returned.
323// Also see Get in package mtastsdb.
324func Get(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (record *Record, policy *Policy, err error) {
325 log := xlog.WithContext(ctx)
327 result := "lookuperror"
329 metricGet.WithLabelValues(result).Observe(float64(time.Since(start)) / float64(time.Second))
330 log.Debugx("mtasts get result", err, mlog.Field("domain", domain), mlog.Field("record", record), mlog.Field("policy", policy), mlog.Field("duration", time.Since(start)))
333 record, _, _, err = LookupRecord(ctx, resolver, domain)
338 result = "fetcherror"
339 policy, _, err = FetchPolicy(ctx, domain)
341 return record, nil, err
345 return record, policy, nil