1// Package mtasts implements MTA-STS (SMTP MTA Strict Transport Security, RFC 8461)
2// which allows a domain to specify SMTP TLS requirements.
3//
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.
12package mtasts
13
14import (
15 "context"
16 "errors"
17 "fmt"
18 "io"
19 "net/http"
20 "strings"
21 "time"
22
23 "github.com/prometheus/client_golang/prometheus"
24 "github.com/prometheus/client_golang/prometheus/promauto"
25
26 "github.com/mjl-/mox/dns"
27 "github.com/mjl-/mox/metrics"
28 "github.com/mjl-/mox/mlog"
29 "github.com/mjl-/mox/moxio"
30)
31
32var xlog = mlog.New("mtasts")
33
34var (
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},
40 },
41 []string{
42 "result", // ok, lookuperror, fetcherror
43 },
44 )
45)
46
47// Pair is an extension key/value pair in a MTA-STS DNS record or policy.
48type Pair struct {
49 Key string
50 Value string
51}
52
53// Record is an MTA-STS DNS record, served under "_mta-sts.<domain>" as a TXT
54// record.
55//
56// Example:
57//
58// v=STSv1; id=20160831085700Z
59type Record struct {
60 Version string // "STSv1", for "v=". Required.
61 ID string // Record version, for "id=". Required.
62 Extensions []Pair // Optional extensions.
63}
64
65// String returns a textual version of the MTA-STS record for use as DNS TXT
66// record.
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)
73 }
74 return b.String()
75}
76
77// Mode indicates how the policy should be interpreted.
78type Mode string
79
80// ../rfc/8461:655
81
82const (
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.
86)
87
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.
90type STSMX struct {
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.
94 Wildcard bool
95
96 Domain dns.Domain
97}
98
99// LogString returns a loggable string representing the host, with both unicode
100// and ascii version for IDNA domains.
101func (s STSMX) LogString() string {
102 pre := ""
103 if s.Wildcard {
104 pre = "*."
105 }
106 if s.Domain.Unicode == "" {
107 return pre + s.Domain.ASCII
108 }
109 return pre + s.Domain.Unicode + "/" + pre + s.Domain.ASCII
110}
111
112// Policy is an MTA-STS policy as served at "https://mta-sts.<domain>/.well-known/mta-sts.txt".
113type Policy struct {
114 Version string // "STSv1"
115 Mode Mode
116 MX []STSMX
117 MaxAgeSeconds int // How long this policy can be cached. Suggested values are in weeks or more.
118 Extensions []Pair
119}
120
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")
126 }
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()
132 if mx.Wildcard {
133 s = "*." + s
134 }
135 line("mx", s)
136 }
137 return b.String()
138}
139
140// Matches returns whether the hostname matches the mx list in the policy.
141func (p *Policy) Matches(host dns.Domain) bool {
142 // ../rfc/8461:636
143 for _, mx := range p.MX {
144 if mx.Wildcard {
145 v := strings.SplitN(host.ASCII, ".", 2)
146 if len(v) == 2 && v[1] == mx.Domain.ASCII {
147 return true
148 }
149 } else if host == mx.Domain {
150 return true
151 }
152 }
153 return false
154}
155
156// Lookup errors.
157var (
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")
162)
163
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)
169 start := time.Now()
170 defer func() {
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)))
172 }()
173
174 // ../rfc/8461:289
175 // ../rfc/8461:351
176 // We lookup the txt record, but must follow CNAME records when the TXT does not exist.
177 var cnames []string
178 name := "_mta-sts." + domain.ASCII + "."
179 var txts []string
180 for {
181 var err error
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")
188 }
189 cname, err := dns.WithPackage(resolver, "mtasts").LookupCNAME(ctx, name)
190 if dns.IsNotFound(err) {
191 return nil, "", cnames, ErrNoRecord
192 }
193 if err != nil {
194 return nil, "", cnames, fmt.Errorf("%w: %s", ErrDNS, err)
195 }
196 cnames = append(cnames, cname)
197 name = cname
198 continue
199 } else if err != nil {
200 return nil, "", cnames, fmt.Errorf("%w: %s", ErrDNS, err)
201 } else {
202 break
203 }
204 }
205
206 var text string
207 var record *Record
208 for _, txt := range txts {
209 r, ismtasts, err := ParseRecord(txt)
210 if !ismtasts {
211 // ../rfc/8461:331 says we should essentially treat a record starting with e.g.
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
214 // reasonable.
215 continue
216 }
217 if err != nil {
218 return nil, "", cnames, err
219 }
220 if record != nil {
221 return nil, "", cnames, ErrMultipleRecords
222 }
223 record = r
224 text = txt
225 }
226 if record == nil {
227 return nil, "", cnames, ErrNoRecord
228 }
229 return record, text, cnames, nil
230}
231
232// Policy fetch errors.
233var (
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")
237)
238
239// HTTPClient is used by FetchPolicy for HTTP requests.
240var HTTPClient = &http.Client{
241 CheckRedirect: func(req *http.Request, via []*http.Request) error {
242 return fmt.Errorf("redirect not allowed for MTA-STS policies") // ../rfc/8461:549
243 },
244}
245
246// FetchPolicy fetches a new policy for the domain, at
247// https://mta-sts.<domain>/.well-known/mta-sts.txt.
248//
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.
252//
253// Policies longer than 64KB result in a syntax error.
254//
255// If an error is returned, callers should back off for 5 minutes until the next
256// attempt.
257func FetchPolicy(ctx context.Context, domain dns.Domain) (policy *Policy, policyText string, rerr error) {
258 log := xlog.WithContext(ctx)
259 start := time.Now()
260 defer func() {
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)))
262 }()
263
264 // Timeout of 1 minute. ../rfc/8461:569
265 ctx, cancel := context.WithTimeout(ctx, time.Minute)
266 defer cancel()
267
268 // TLS requirements are what the Go standard library checks: trusted, non-expired,
269 // hostname validated against DNS-ID supporting wildcard. ../rfc/8461:524
270 url := "https://mta-sts." + domain.Name() + "/.well-known/mta-sts.txt"
271 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
272 if err != nil {
273 return nil, "", fmt.Errorf("%w: http request: %s", ErrPolicyFetch, err)
274 }
275 // We are not likely to reuse a connection: we cache policies and negative DNS
276 // responses. So don't keep connections open unnecessarily.
277 req.Close = true
278
279 resp, err := HTTPClient.Do(req)
280 if dns.IsNotFound(err) {
281 return nil, "", ErrNoPolicy
282 }
283 if err != nil {
284 return nil, "", fmt.Errorf("%w: http get: %s", ErrPolicyFetch, err)
285 }
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
290 }
291 if resp.StatusCode != http.StatusOK {
292 // ../rfc/8461:548
293 return nil, "", fmt.Errorf("%w: http status %s while status 200 is required", ErrPolicyFetch, resp.Status)
294 }
295
296 // We don't look at Content-Type and charset. It should be ASCII or UTF-8, we'll
297 // just always whatever is sent as UTF-8. ../rfc/8461:367
298
299 // ../rfc/8461:570
300 buf, err := io.ReadAll(&moxio.LimitReader{R: resp.Body, Limit: 64 * 1024})
301 if err != nil {
302 return nil, "", fmt.Errorf("%w: reading policy: %s", ErrPolicySyntax, err)
303 }
304 policyText = string(buf)
305 policy, err = ParsePolicy(policyText)
306 if err != nil {
307 return nil, policyText, fmt.Errorf("parsing policy: %w", err)
308 }
309 return policy, policyText, nil
310}
311
312// Get looks up the MTA-STS DNS record and fetches the policy.
313//
314// Errors can be those returned by LookupRecord and FetchPolicy.
315//
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
318// would still apply.
319//
320// If a record was retrieved, but a policy could not be retrieved/parsed, the
321// record is still returned.
322//
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)
326 start := time.Now()
327 result := "lookuperror"
328 defer func() {
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)))
331 }()
332
333 record, _, _, err = LookupRecord(ctx, resolver, domain)
334 if err != nil {
335 return nil, nil, err
336 }
337
338 result = "fetcherror"
339 policy, _, err = FetchPolicy(ctx, domain)
340 if err != nil {
341 return record, nil, err
342 }
343
344 result = "ok"
345 return record, policy, nil
346}
347