internal/mailin/authres.go
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}