1package spf
2
3import (
4 "fmt"
5 "net"
6 "strconv"
7 "strings"
8)
9
10// Record is a parsed SPF DNS record.
11//
12// An example record for example.com:
13//
14// v=spf1 +mx a:colo.example.com/28 -all
15type Record struct {
16 Version string // Must be "spf1".
17 Directives []Directive // An IP is evaluated against each directive until a match is found.
18 Redirect string // Modifier that redirects SPF checks to other domain after directives did not match. Optional. For "redirect=".
19 Explanation string // Modifier for creating a user-friendly error message when an IP results in status "fail".
20 Other []Modifier // Other modifiers.
21}
22
23// Directive consists of a mechanism that describes how to check if an IP matches,
24// an (optional) qualifier indicating the policy for a match, and optional
25// parameters specific to the mechanism.
26type Directive struct {
27 Qualifier string // Sets the result if this directive matches. "" and "+" are "pass", "-" is "fail", "?" is "neutral", "~" is "softfail".
28 Mechanism string // "all", "include", "a", "mx", "ptr", "ip4", "ip6", "exists".
29 DomainSpec string // For include, a, mx, ptr, exists. Always in lower-case when parsed using ParseRecord.
30 IP net.IP `json:"-"` // For ip4, ip6.
31 IPstr string // Original string for IP, always with /subnet.
32 IP4CIDRLen *int // For a, mx, ip4.
33 IP6CIDRLen *int // For a, mx, ip6.
34}
35
36// MechanismString returns a directive in string form for use in the Received-SPF header.
37func (d Directive) MechanismString() string {
38 s := d.Qualifier + d.Mechanism
39 if d.DomainSpec != "" {
40 s += ":" + d.DomainSpec
41 } else if d.IP != nil {
42 s += ":" + d.IP.String()
43 }
44 if d.IP4CIDRLen != nil {
45 s += fmt.Sprintf("/%d", *d.IP4CIDRLen)
46 }
47 if d.IP6CIDRLen != nil {
48 if d.Mechanism != "ip6" {
49 s += "/"
50 }
51 s += fmt.Sprintf("/%d", *d.IP6CIDRLen)
52 }
53 return s
54}
55
56// Modifier provides additional information for a policy.
57// "redirect" and "exp" are not represented as a Modifier but explicitly in a Record.
58type Modifier struct {
59 Key string // Key is case-insensitive.
60 Value string
61}
62
63// Record returns an DNS record, to be configured as a TXT record for a domain,
64// e.g. a TXT record for example.com.
65func (r Record) Record() (string, error) {
66 b := &strings.Builder{}
67 b.WriteString("v=")
68 b.WriteString(r.Version)
69 for _, d := range r.Directives {
70 b.WriteString(" " + d.MechanismString())
71 }
72 if r.Redirect != "" {
73 fmt.Fprintf(b, " redirect=%s", r.Redirect)
74 }
75 if r.Explanation != "" {
76 fmt.Fprintf(b, " exp=%s", r.Explanation)
77 }
78 for _, m := range r.Other {
79 fmt.Fprintf(b, " %s=%s", m.Key, m.Value)
80 }
81 return b.String(), nil
82}
83
84type parser struct {
85 s string
86 lower string
87 o int
88}
89
90type parseError string
91
92func (e parseError) Error() string {
93 return string(e)
94}
95
96// toLower lower cases bytes that are A-Z. strings.ToLower does too much. and
97// would replace invalid bytes with unicode replacement characters, which would
98// break our requirement that offsets into the original and upper case strings
99// point to the same character.
100func toLower(s string) string {
101 r := []byte(s)
102 for i, c := range r {
103 if c >= 'A' && c <= 'Z' {
104 r[i] = c + 0x20
105 }
106 }
107 return string(r)
108}
109
110// ParseRecord parses an SPF DNS TXT record.
111func ParseRecord(s string) (r *Record, isspf bool, rerr error) {
112 p := parser{s: s, lower: toLower(s)}
113
114 r = &Record{
115 Version: "spf1",
116 }
117
118 defer func() {
119 x := recover()
120 if x == nil {
121 return
122 }
123 if err, ok := x.(parseError); ok {
124 rerr = err
125 return
126 }
127 panic(x)
128 }()
129
130 p.xtake("v=spf1")
131 for !p.empty() {
132 p.xtake(" ")
133 isspf = true // ../rfc/7208:825
134 for p.take(" ") {
135 }
136 if p.empty() {
137 break
138 }
139
140 qualifier := p.takelist("+", "-", "?", "~")
141 mechanism := p.takelist("all", "include:", "a", "mx", "ptr", "ip4:", "ip6:", "exists:")
142 if qualifier != "" && mechanism == "" {
143 p.xerrorf("expected mechanism after qualifier")
144 }
145 if mechanism == "" {
146 // ../rfc/7208:2597
147 modifier := p.takelist("redirect=", "exp=")
148 if modifier == "" {
149 // ../rfc/7208:2600
150 name := p.xtakefn1(func(c rune, i int) bool {
151 alpha := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
152 return alpha || i > 0 && (c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.')
153 })
154 p.xtake("=")
155 v := p.xmacroString(true)
156 r.Other = append(r.Other, Modifier{name, v})
157 continue
158 }
159 v := p.xdomainSpec(true)
160 modifier = strings.TrimSuffix(modifier, "=")
161 if modifier == "redirect" {
162 if r.Redirect != "" {
163 // ../rfc/7208:1419
164 p.xerrorf("duplicate redirect modifier")
165 }
166 r.Redirect = v
167 }
168 if modifier == "exp" {
169 if r.Explanation != "" {
170 // ../rfc/7208:1419
171 p.xerrorf("duplicate exp modifier")
172 }
173 r.Explanation = v
174 }
175 continue
176 }
177 // ../rfc/7208:2585
178 d := Directive{
179 Qualifier: qualifier,
180 Mechanism: strings.TrimSuffix(mechanism, ":"),
181 }
182 switch d.Mechanism {
183 case "all":
184 case "include":
185 d.DomainSpec = p.xdomainSpec(false)
186 case "a", "mx":
187 if p.take(":") {
188 d.DomainSpec = p.xdomainSpec(false)
189 }
190 if p.take("/") {
191 if !p.take("/") {
192 num, _ := p.xnumber()
193 if num > 32 {
194 p.xerrorf("invalid ip4 cidr length %d", num)
195 }
196 d.IP4CIDRLen = &num
197 if !p.take("//") {
198 break
199 }
200 }
201 num, _ := p.xnumber()
202 if num > 128 {
203 p.xerrorf("invalid ip6 cidr length %d", num)
204 }
205 d.IP6CIDRLen = &num
206 }
207 case "ptr":
208 if p.take(":") {
209 d.DomainSpec = p.xdomainSpec(false)
210 }
211 case "ip4":
212 d.IP, d.IPstr = p.xip4address()
213 if p.take("/") {
214 num, _ := p.xnumber()
215 if num > 32 {
216 p.xerrorf("invalid ip4 cidr length %d", num)
217 }
218 d.IP4CIDRLen = &num
219 d.IPstr += fmt.Sprintf("/%d", num)
220 } else {
221 d.IPstr += "/32"
222 }
223 case "ip6":
224 d.IP, d.IPstr = p.xip6address()
225 if p.take("/") {
226 num, _ := p.xnumber()
227 if num > 128 {
228 p.xerrorf("invalid ip6 cidr length %d", num)
229 }
230 d.IP6CIDRLen = &num
231 d.IPstr += fmt.Sprintf("/%d", num)
232 } else {
233 d.IPstr += "/128"
234 }
235 case "exists":
236 d.DomainSpec = p.xdomainSpec(false)
237 default:
238 return nil, true, fmt.Errorf("internal error, missing case for mechanism %q", d.Mechanism)
239 }
240 r.Directives = append(r.Directives, d)
241 }
242 return r, true, nil
243}
244
245func (p *parser) xerrorf(format string, args ...any) {
246 msg := fmt.Sprintf(format, args...)
247 if !p.empty() {
248 msg += fmt.Sprintf(" (leftover %q)", p.s[p.o:])
249 }
250 panic(parseError(msg))
251}
252
253// operates on original-cased characters.
254func (p *parser) xtakefn1(fn func(rune, int) bool) string {
255 r := ""
256 for i, c := range p.s[p.o:] {
257 if !fn(c, i) {
258 break
259 }
260 r += string(c)
261 }
262 if r == "" {
263 p.xerrorf("need at least 1 char")
264 }
265 p.o += len(r)
266 return r
267}
268
269// caller should set includingSlash to false when parsing "a" or "mx", or the / would be consumed as valid macro literal.
270func (p *parser) xdomainSpec(includingSlash bool) string {
271 // ../rfc/7208:1579
272 // This also consumes the "domain-end" part, which we check below.
273 s := p.xmacroString(includingSlash)
274
275 // The ABNF says s must either end in macro-expand, or "." toplabel ["."]. The
276 // toplabel rule implies the intention is to force a valid DNS name. We cannot just
277 // check if the name is valid, because "macro-expand" is not a valid label. So we
278 // recognize the macro-expand, and check for valid toplabel otherwise, because we
279 // syntax errors must result in Permerror.
280 for _, suf := range []string{"%%", "%_", "%-", "}"} {
281 // The check for "}" assumes a "%{" precedes it...
282 if strings.HasSuffix(s, suf) {
283 return s
284 }
285 }
286 tl := strings.Split(strings.TrimSuffix(s, "."), ".")
287 t := tl[len(tl)-1]
288 if t == "" {
289 p.xerrorf("invalid empty toplabel")
290 }
291 nums := 0
292 for i, c := range t {
293 switch {
294 case c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z':
295 case c >= '0' && c <= '9':
296 nums++
297 case c == '-':
298 if i == 0 {
299 p.xerrorf("bad toplabel, invalid leading dash")
300 }
301 if i == len(t)-1 {
302 p.xerrorf("bad toplabel, invalid trailing dash")
303 }
304 default:
305 p.xerrorf("bad toplabel, invalid character")
306 }
307 }
308 if nums == len(t) {
309 p.xerrorf("bad toplabel, cannot be all digits")
310 }
311 return s
312}
313
314func (p *parser) xmacroString(includingSlash bool) string {
315 // ../rfc/7208:1588
316 r := ""
317 for !p.empty() {
318 w := p.takelist("%{", "%%", "%_", "%-") // "macro-expand"
319 if w == "" {
320 // "macro-literal"
321 if !p.empty() {
322 b := p.peekchar()
323 if b > ' ' && b < 0x7f && b != '%' && (includingSlash || b != '/') {
324 r += string(b)
325 p.o++
326 continue
327 }
328 }
329 break
330 }
331 r += w
332 if w != "%{" {
333 continue
334 }
335 r += p.xtakelist("s", "l", "o", "d", "i", "p", "h", "c", "r", "t", "v") // "macro-letter"
336 digits := p.digits()
337 if digits != "" {
338 if v, err := strconv.Atoi(digits); err != nil {
339 p.xerrorf("bad digits: %v", err)
340 } else if v == 0 {
341 p.xerrorf("bad digits 0 for 0 labels")
342 }
343 }
344 r += digits
345 if p.take("r") {
346 r += "r"
347 }
348 for {
349 delimiter := p.takelist(".", "-", "+", ",", "/", "_", "=")
350 if delimiter == "" {
351 break
352 }
353 r += delimiter
354 }
355 r += p.xtake("}")
356 }
357 return r
358}
359
360func (p *parser) empty() bool {
361 return p.o >= len(p.s)
362}
363
364// returns next original-cased character.
365func (p *parser) peekchar() byte {
366 return p.s[p.o]
367}
368
369func (p *parser) xtakelist(l ...string) string {
370 w := p.takelist(l...)
371 if w == "" {
372 p.xerrorf("no match for %v", l)
373 }
374 return w
375}
376
377func (p *parser) takelist(l ...string) string {
378 for _, w := range l {
379 if strings.HasPrefix(p.lower[p.o:], w) {
380 p.o += len(w)
381 return w
382 }
383 }
384 return ""
385}
386
387// digits parses zero or more digits.
388func (p *parser) digits() string {
389 r := ""
390 for !p.empty() {
391 b := p.peekchar()
392 if b >= '0' && b <= '9' {
393 r += string(b)
394 p.o++
395 } else {
396 break
397 }
398 }
399 return r
400}
401
402func (p *parser) take(s string) bool {
403 if strings.HasPrefix(p.lower[p.o:], s) {
404 p.o += len(s)
405 return true
406 }
407 return false
408}
409
410func (p *parser) xtake(s string) string {
411 ok := p.take(s)
412 if !ok {
413 p.xerrorf("expected %q", s)
414 }
415 return s
416}
417
418func (p *parser) xnumber() (int, string) {
419 s := p.digits()
420 if s == "" {
421 p.xerrorf("expected number")
422 }
423 if s == "0" {
424 return 0, s
425 }
426 if strings.HasPrefix(s, "0") {
427 p.xerrorf("bogus leading 0 in number")
428 }
429 v, err := strconv.Atoi(s)
430 if err != nil {
431 p.xerrorf("parsing number for %q: %s", s, err)
432 }
433 return v, s
434}
435
436func (p *parser) xip4address() (net.IP, string) {
437 // ../rfc/7208:2607
438 ip4num := func() (byte, string) {
439 v, vs := p.xnumber()
440 if v > 255 {
441 p.xerrorf("bad ip4 number %d", v)
442 }
443 return byte(v), vs
444 }
445 a, as := ip4num()
446 p.xtake(".")
447 b, bs := ip4num()
448 p.xtake(".")
449 c, cs := ip4num()
450 p.xtake(".")
451 d, ds := ip4num()
452 return net.IPv4(a, b, c, d), as + "." + bs + "." + cs + "." + ds
453}
454
455func (p *parser) xip6address() (net.IP, string) {
456 // ../rfc/7208:2614
457 // We just take in a string that has characters that IPv6 uses, then parse it.
458 s := p.xtakefn1(func(c rune, i int) bool {
459 return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' || c == ':' || c == '.'
460 })
461 ip := net.ParseIP(s)
462 if ip == nil {
463 p.xerrorf("ip6 address %q not valid", s)
464 }
465 return ip, s
466}
467