internal/mailin/authres.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/mailin/authres.go history · blame · raw

206 lines · 5344 bytes

11 symbols in this file
  1package mailin
  2
  3import (
  4	"net/mail"
  5	"strings"
  6
  7	"golang.org/x/net/publicsuffix"
  8)
  9
 10// authenticated checks the sender against the mail host's own verdict:
 11// the topmost Authentication-Results header (RFC 8601) whose authserv-id
 12// is authserv. The mail host adds its header above any the message
 13// arrived with, so a lower header claiming the same id is the sender's
 14// and is not read. It returns "" when the header shows dmarc=pass for
 15// the From domain, or dkim=pass with a header.d aligned with it, and the
 16// refusal's reason otherwise.
 17func authenticated(h mail.Header, authserv, from string) string {
 18	_, fromDomain, ok := strings.Cut(strings.ToLower(from), "@")
 19	if !ok || fromDomain == "" {
 20		return "no From domain"
 21	}
 22	for _, v := range h["Authentication-Results"] {
 23		segs := splitResults(tokenize(v))
 24		if len(segs) == 0 || len(segs[0]) == 0 || segs[0][0].kind != tokAtom ||
 25			!strings.EqualFold(segs[0][0].text, authserv) {
 26			continue
 27		}
 28		for _, seg := range segs[1:] {
 29			method, result, props, ok := resinfo(seg)
 30			if !ok || result != "pass" {
 31				continue
 32			}
 33			switch method {
 34			case "dmarc":
 35				if props["header.from"] == fromDomain {
 36					return ""
 37				}
 38			case "dkim":
 39				if aligned(props["header.d"], fromDomain) {
 40					return ""
 41				}
 42			}
 43		}
 44		return "sender not authenticated by " + authserv + " (no DMARC pass or aligned DKIM pass)"
 45	}
 46	return "no Authentication-Results from " + authserv
 47}
 48
 49type tokKind int
 50
 51const (
 52	tokAtom tokKind = iota
 53	tokQuoted
 54	tokEquals
 55	tokSemi
 56)
 57
 58type token struct {
 59	kind tokKind
 60	text string // an atom's text, or a quoted string's content unescaped
 61	// joined marks a token with no whitespace or comment before it, so
 62	// "x"@example.org is one value.
 63	joined bool
 64}
 65
 66// tokenize splits a header value into atoms, quoted strings, "=" and
 67// ";". Comments, nested or not, and whitespace separate tokens and are
 68// dropped; backslash escapes are honoured in both comments and quoted
 69// strings, so nothing inside either can end it early. An unterminated
 70// quoted string or comment runs to the end of the value.
 71func tokenize(s string) []token {
 72	var out []token
 73	joined := false
 74	emit := func(t token) {
 75		t.joined = joined
 76		out = append(out, t)
 77		joined = true
 78	}
 79	for i := 0; i < len(s); {
 80		c := s[i]
 81		switch {
 82		case c == ' ' || c == '\t' || c == '\r' || c == '\n':
 83			joined = false
 84			i++
 85		case c == '(':
 86			depth := 0
 87			for ; i < len(s); i++ {
 88				if s[i] == '\\' {
 89					i++
 90					continue
 91				}
 92				if s[i] == '(' {
 93					depth++
 94				} else if s[i] == ')' {
 95					depth--
 96					if depth == 0 {
 97						i++
 98						break
 99					}
100				}
101			}
102			joined = false
103		case c == '"':
104			var b strings.Builder
105			i++
106			for i < len(s) && s[i] != '"' {
107				if s[i] == '\\' && i+1 < len(s) {
108					i++
109				}
110				b.WriteByte(s[i])
111				i++
112			}
113			i++ // the closing quote
114			emit(token{kind: tokQuoted, text: b.String()})
115		case c == '=':
116			emit(token{kind: tokEquals})
117			i++
118		case c == ';':
119			emit(token{kind: tokSemi})
120			i++
121		default:
122			j := i
123			for j < len(s) && !strings.ContainsRune(" \t\r\n()\";=\\", rune(s[j])) {
124				j++
125			}
126			if j == i { // a stray backslash
127				j++
128			}
129			emit(token{kind: tokAtom, text: s[i:j]})
130			i = j
131		}
132	}
133	return out
134}
135
136// splitResults splits tokens at each ";".
137func splitResults(ts []token) [][]token {
138	segs := [][]token{nil}
139	for _, t := range ts {
140		if t.kind == tokSemi {
141			segs = append(segs, nil)
142			continue
143		}
144		segs[len(segs)-1] = append(segs[len(segs)-1], t)
145	}
146	return segs
147}
148
149// resinfo reads "method[/version]=result" and the "name=value" pairs
150// after it. Method, result and each value must be plain atoms; a quoted
151// value (a reason, or a quoted local part) is kept only as a quoted
152// value and never read as a domain. ok is false when the method does
153// not parse.
154func resinfo(ts []token) (method, result string, props map[string]string, ok bool) {
155	props = map[string]string{}
156	if len(ts) < 3 || ts[0].kind != tokAtom || ts[1].kind != tokEquals || ts[2].kind != tokAtom {
157		return "", "", props, false
158	}
159	method, _, _ = strings.Cut(strings.ToLower(ts[0].text), "/")
160	result = strings.ToLower(ts[2].text)
161	i := 3
162	// A value continues through tokens joined to it: "x"@example.org.
163	for i < len(ts) && ts[i].joined && ts[i].kind != tokEquals {
164		i++
165	}
166	for i < len(ts) {
167		if ts[i].kind != tokAtom || i+2 >= len(ts) || ts[i+1].kind != tokEquals {
168			i++
169			continue
170		}
171		name := strings.ToLower(ts[i].text)
172		v := ts[i+2]
173		j := i + 3
174		plain := v.kind == tokAtom
175		for j < len(ts) && ts[j].joined && ts[j].kind != tokEquals {
176			plain = false
177			j++
178		}
179		// The first value for a name is the one the mail host wrote
180		// beside the result.
181		if _, seen := props[name]; !seen {
182			if plain {
183				props[name] = strings.ToLower(v.text)
184			} else {
185				props[name] = "" // present, but not a plain domain
186			}
187		}
188		i = j
189	}
190	return method, result, props, true
191}
192
193// aligned is DMARC relaxed alignment: the signing domain and the From
194// domain have the same organizational domain (public suffix plus one
195// label). A domain that is itself a public suffix aligns with nothing.
196func aligned(d, from string) bool {
197	if d == "" {
198		return false
199	}
200	od, err := publicsuffix.EffectiveTLDPlusOne(d)
201	if err != nil {
202		return false
203	}
204	of, err := publicsuffix.EffectiveTLDPlusOne(from)
205	return err == nil && od == of
206}