1package dmarc
2
3import (
4 "fmt"
5 "net/url"
6 "strconv"
7 "strings"
8)
9
10type parseErr string
11
12func (e parseErr) Error() string {
13 return string(e)
14}
15
16// ParseRecord parses a DMARC TXT record.
17//
18// Fields and values are are case-insensitive in DMARC are returned in lower case
19// for easy comparison.
20//
21// DefaultRecord provides default values for tags not present in s.
22//
23// isdmarc indicates if the record starts tag "v" with value "DMARC1", and should
24// be treated as a valid DMARC record. Used to detect possibly multiple DMARC
25// records (invalid) for a domain with multiple TXT record (quite common).
26func ParseRecord(s string) (record *Record, isdmarc bool, rerr error) {
27 return parseRecord(s, true)
28}
29
30// ParseRecordNoRequired is like ParseRecord, but don't check for required fields
31// for regular DMARC records. Useful for checking the _report._dmarc record,
32// used for opting into receiving reports for other domains.
33func ParseRecordNoRequired(s string) (record *Record, isdmarc bool, rerr error) {
34 return parseRecord(s, false)
35}
36
37func parseRecord(s string, checkRequired bool) (record *Record, isdmarc bool, rerr error) {
38 defer func() {
39 x := recover()
40 if x == nil {
41 return
42 }
43 if err, ok := x.(parseErr); ok {
44 rerr = err
45 return
46 }
47 panic(x)
48 }()
49
50 r := DefaultRecord
51 p := newParser(s)
52
53 // v= is required and must be first. ../rfc/7489:1099
54 p.xtake("v")
55 p.wsp()
56 p.xtake("=")
57 p.wsp()
58 r.Version = p.xtakecase("DMARC1")
59 p.wsp()
60 p.xtake(";")
61 isdmarc = true
62 seen := map[string]bool{}
63 for {
64 p.wsp()
65 if p.empty() {
66 break
67 }
68 W := p.xword()
69 w := strings.ToLower(W)
70 if seen[w] {
71 // RFC does not say anything about duplicate tags. They can only confuse, so we
72 // don't allow them.
73 p.xerrorf("duplicate tag %q", W)
74 }
75 seen[w] = true
76 p.wsp()
77 p.xtake("=")
78 p.wsp()
79 switch w {
80 default:
81 // ../rfc/7489:924 implies that we should know how to parse unknown tags.
82 // The formal definition at ../rfc/7489:1127 does not allow for unknown tags.
83 // We just parse until the next semicolon or end.
84 for !p.empty() {
85 if p.peek(';') {
86 break
87 }
88 p.xtaken(1)
89 }
90 case "p":
91 if len(seen) != 1 {
92 // ../rfc/7489:1105
93 p.xerrorf("p= (policy) must be first tag")
94 }
95 r.Policy = DMARCPolicy(p.xtakelist("none", "quarantine", "reject"))
96 case "sp":
97 r.SubdomainPolicy = DMARCPolicy(p.xkeyword())
98 // note: we check if the value is valid before returning.
99 case "rua":
100 r.AggregateReportAddresses = append(r.AggregateReportAddresses, p.xuri())
101 p.wsp()
102 for p.take(",") {
103 p.wsp()
104 r.AggregateReportAddresses = append(r.AggregateReportAddresses, p.xuri())
105 p.wsp()
106 }
107 case "ruf":
108 r.FailureReportAddresses = append(r.FailureReportAddresses, p.xuri())
109 p.wsp()
110 for p.take(",") {
111 p.wsp()
112 r.FailureReportAddresses = append(r.FailureReportAddresses, p.xuri())
113 p.wsp()
114 }
115 case "adkim":
116 r.ADKIM = Align(p.xtakelist("r", "s"))
117 case "aspf":
118 r.ASPF = Align(p.xtakelist("r", "s"))
119 case "ri":
120 r.AggregateReportingInterval = p.xnumber()
121 case "fo":
122 r.FailureReportingOptions = []string{p.xtakelist("0", "1", "d", "s")}
123 p.wsp()
124 for p.take(":") {
125 p.wsp()
126 r.FailureReportingOptions = append(r.FailureReportingOptions, p.xtakelist("0", "1", "d", "s"))
127 p.wsp()
128 }
129 case "rf":
130 r.ReportingFormat = []string{p.xkeyword()}
131 p.wsp()
132 for p.take(":") {
133 p.wsp()
134 r.ReportingFormat = append(r.ReportingFormat, p.xkeyword())
135 p.wsp()
136 }
137 case "pct":
138 r.Percentage = p.xnumber()
139 if r.Percentage > 100 {
140 p.xerrorf("bad percentage %d", r.Percentage)
141 }
142 }
143 p.wsp()
144 if !p.take(";") && !p.empty() {
145 p.xerrorf("expected ;")
146 }
147 }
148
149 // ../rfc/7489:1106 says "p" is required, but ../rfc/7489:1407 implies we must be
150 // able to parse a record without a "p" or with invalid "sp" tag.
151 sp := r.SubdomainPolicy
152 if checkRequired && (!seen["p"] || sp != PolicyEmpty && sp != PolicyNone && sp != PolicyQuarantine && sp != PolicyReject) {
153 if len(r.AggregateReportAddresses) > 0 {
154 r.Policy = PolicyNone
155 r.SubdomainPolicy = PolicyEmpty
156 } else {
157 p.xerrorf("invalid (subdomain)policy and no valid aggregate reporting address")
158 }
159 }
160
161 return &r, true, nil
162}
163
164type parser struct {
165 s string
166 lower string
167 o int
168}
169
170// toLower lower cases bytes that are A-Z. strings.ToLower does too much. and
171// would replace invalid bytes with unicode replacement characters, which would
172// break our requirement that offsets into the original and upper case strings
173// point to the same character.
174func toLower(s string) string {
175 r := []byte(s)
176 for i, c := range r {
177 if c >= 'A' && c <= 'Z' {
178 r[i] = c + 0x20
179 }
180 }
181 return string(r)
182}
183
184func newParser(s string) *parser {
185 return &parser{
186 s: s,
187 lower: toLower(s),
188 }
189}
190
191func (p *parser) xerrorf(format string, args ...any) {
192 msg := fmt.Sprintf(format, args...)
193 if p.o < len(p.s) {
194 msg += fmt.Sprintf(" (remain %q)", p.s[p.o:])
195 }
196 panic(parseErr(msg))
197}
198
199func (p *parser) empty() bool {
200 return p.o >= len(p.s)
201}
202
203func (p *parser) peek(b byte) bool {
204 return p.o < len(p.s) && p.s[p.o] == b
205}
206
207// case insensitive prefix
208func (p *parser) prefix(s string) bool {
209 return strings.HasPrefix(p.lower[p.o:], s)
210}
211
212func (p *parser) take(s string) bool {
213 if p.prefix(s) {
214 p.o += len(s)
215 return true
216 }
217 return false
218}
219
220func (p *parser) xtaken(n int) string {
221 r := p.lower[p.o : p.o+n]
222 p.o += n
223 return r
224}
225
226func (p *parser) xtake(s string) string {
227 if !p.prefix(s) {
228 p.xerrorf("expected %q", s)
229 }
230 return p.xtaken(len(s))
231}
232
233func (p *parser) xtakecase(s string) string {
234 if !strings.HasPrefix(p.s[p.o:], s) {
235 p.xerrorf("expected %q", s)
236 }
237 r := p.s[p.o : p.o+len(s)]
238 p.o += len(s)
239 return r
240}
241
242// *WSP
243func (p *parser) wsp() {
244 for !p.empty() && (p.s[p.o] == ' ' || p.s[p.o] == '\t') {
245 p.o++
246 }
247}
248
249// take one of the strings in l.
250func (p *parser) xtakelist(l ...string) string {
251 for _, s := range l {
252 if p.prefix(s) {
253 return p.xtaken(len(s))
254 }
255 }
256 p.xerrorf("expected on one %v", l)
257 panic("not reached")
258}
259
260func (p *parser) xtakefn1case(fn func(byte, int) bool) string {
261 for i, b := range []byte(p.lower[p.o:]) {
262 if !fn(b, i) {
263 if i == 0 {
264 p.xerrorf("expected at least one char")
265 }
266 return p.xtaken(i)
267 }
268 }
269 if p.empty() {
270 p.xerrorf("expected at least 1 char")
271 }
272 r := p.s[p.o:]
273 p.o += len(r)
274 return r
275}
276
277// used for the tag keys.
278func (p *parser) xword() string {
279 return p.xtakefn1case(func(c byte, i int) bool {
280 return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
281 })
282}
283
284func (p *parser) xdigits() string {
285 return p.xtakefn1case(func(b byte, i int) bool {
286 return isdigit(b)
287 })
288}
289
290// ../rfc/7489:883
291// Syntax: ../rfc/7489:1132
292func (p *parser) xuri() URI {
293 // Ideally, we would simply parse an URI here. But a URI can contain a semicolon so
294 // could consume the rest of the DMARC record. Instead, we'll assume no one uses
295 // semicolons in URIs in DMARC records and first collect
296 // space/comma/semicolon/end-separated characters, then parse.
297 // ../rfc/3986:684
298 v := p.xtakefn1case(func(b byte, i int) bool {
299 return b != ',' && b != ' ' && b != '\t' && b != ';'
300 })
301 t := strings.SplitN(v, "!", 2)
302 u, err := url.Parse(t[0])
303 if err != nil {
304 p.xerrorf("parsing uri %q: %s", t[0], err)
305 }
306 if u.Scheme == "" {
307 p.xerrorf("missing scheme in uri")
308 }
309 uri := URI{
310 Address: t[0],
311 }
312 if len(t) == 2 {
313 o := t[1]
314 if o != "" {
315 c := o[len(o)-1]
316 switch c {
317 case 'k', 'K', 'm', 'M', 'g', 'G', 't', 'T':
318 uri.Unit = strings.ToLower(o[len(o)-1:])
319 o = o[:len(o)-1]
320 }
321 }
322 uri.MaxSize, err = strconv.ParseUint(o, 10, 64)
323 if err != nil {
324 p.xerrorf("parsing max size for uri: %s", err)
325 }
326 }
327 return uri
328}
329
330func (p *parser) xnumber() int {
331 digits := p.xdigits()
332 v, err := strconv.Atoi(digits)
333 if err != nil {
334 p.xerrorf("parsing %q: %s", digits, err)
335 }
336 return v
337}
338
339func (p *parser) xkeyword() string {
340 // ../rfc/7489:1195, keyword is imported from smtp.
341 // ../rfc/5321:2287
342 n := len(p.s) - p.o
343 return p.xtakefn1case(func(b byte, i int) bool {
344 return isalphadigit(b) || (b == '-' && i < n-1 && isalphadigit(p.s[p.o+i+1]))
345 })
346}
347
348func isdigit(b byte) bool {
349 return b >= '0' && b <= '9'
350}
351
352func isalpha(b byte) bool {
353 return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
354}
355
356func isalphadigit(b byte) bool {
357 return isdigit(b) || isalpha(b)
358}
359