git » debian:golang-blitiri-go-spf » master » tree

[master] / spf.go

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
// Package spf implements SPF (Sender Policy Framework) lookup and validation.
//
// Sender Policy Framework (SPF) is a simple email-validation system designed
// to detect email spoofing by providing a mechanism to allow receiving mail
// exchangers to check that incoming mail from a domain comes from a host
// authorized by that domain's administrators [Wikipedia].
//
// This is a Go implementation of it, which is used by the chasquid SMTP
// server (https://blitiri.com.ar/p/chasquid/).
//
// Supported mechanisms and modifiers:
//   all
//   include
//   a
//   mx
//   ip4
//   ip6
//   redirect
//
// Not supported (return Neutral if used):
//   exists
//   exp
//   Macros
//
// This is intentional and there are no plans to add them for now, as they are
// very rare, convoluted and not worth the additional complexity.
//
// References:
//   https://tools.ietf.org/html/rfc7208
//   https://en.wikipedia.org/wiki/Sender_Policy_Framework
package spf // import "blitiri.com.ar/go/spf"

import (
	"fmt"
	"net"
	"regexp"
	"strconv"
	"strings"
)

// Functions that we can override for testing purposes.
var (
	lookupTXT  = net.LookupTXT
	lookupMX   = net.LookupMX
	lookupIP   = net.LookupIP
	lookupAddr = net.LookupAddr
)

// Results and Errors. Note the values have meaning, we use them in headers.
// https://tools.ietf.org/html/rfc7208#section-8
type Result string

// Valid results.
var (
	// https://tools.ietf.org/html/rfc7208#section-8.1
	// Not able to reach any conclusion.
	None = Result("none")

	// https://tools.ietf.org/html/rfc7208#section-8.2
	// No definite assertion (positive or negative).
	Neutral = Result("neutral")

	// https://tools.ietf.org/html/rfc7208#section-8.3
	// Client is authorized to inject mail.
	Pass = Result("pass")

	// https://tools.ietf.org/html/rfc7208#section-8.4
	// Client is *not* authorized to use the domain
	Fail = Result("fail")

	// https://tools.ietf.org/html/rfc7208#section-8.5
	// Not authorized, but unwilling to make a strong policy statement/
	SoftFail = Result("softfail")

	// https://tools.ietf.org/html/rfc7208#section-8.6
	// Transient error while performing the check.
	TempError = Result("temperror")

	// https://tools.ietf.org/html/rfc7208#section-8.7
	// Records could not be correctly interpreted.
	PermError = Result("permerror")
)

var qualToResult = map[byte]Result{
	'+': Pass,
	'-': Fail,
	'~': SoftFail,
	'?': Neutral,
}

// CheckHost fetches SPF records for `domain`, parses them, and evaluates them
// to determine if `ip` is permitted to send mail for it.
// Reference: https://tools.ietf.org/html/rfc7208#section-4
func CheckHost(ip net.IP, domain string) (Result, error) {
	r := &resolution{ip, 0, nil}
	return r.Check(domain)
}

type resolution struct {
	ip    net.IP
	count uint

	// Result of doing a reverse lookup for ip (so we only do it once).
	ipNames []string
}

func (r *resolution) Check(domain string) (Result, error) {
	// Limit the number of resolutions to 10
	// https://tools.ietf.org/html/rfc7208#section-4.6.4
	if r.count > 10 {
		return PermError, fmt.Errorf("lookup limit reached")
	}
	r.count++

	txt, err := getDNSRecord(domain)
	if err != nil {
		if isTemporary(err) {
			return TempError, err
		}
		// Could not resolve the name, it may be missing the record.
		// https://tools.ietf.org/html/rfc7208#section-2.6.1
		return None, err
	}

	if txt == "" {
		// No record => None.
		// https://tools.ietf.org/html/rfc7208#section-4.6
		return None, nil
	}

	fields := strings.Fields(txt)

	// redirects must be handled after the rest; instead of having two loops,
	// we just move them to the end.
	var newfields, redirects []string
	for _, field := range fields {
		if strings.HasPrefix(field, "redirect:") {
			redirects = append(redirects, field)
		} else {
			newfields = append(newfields, field)
		}
	}
	fields = append(newfields, redirects...)

	for _, field := range fields {
		if strings.HasPrefix(field, "v=") {
			continue
		}
		if r.count > 10 {
			return PermError, fmt.Errorf("lookup limit reached")
		}
		if strings.Contains(field, "%") {
			return Neutral, fmt.Errorf("macros not supported")
		}

		// See if we have a qualifier, defaulting to + (pass).
		// https://tools.ietf.org/html/rfc7208#section-4.6.2
		result, ok := qualToResult[field[0]]
		if ok {
			field = field[1:]
		} else {
			result = Pass
		}

		if field == "all" {
			// https://tools.ietf.org/html/rfc7208#section-5.1
			return result, fmt.Errorf("matched 'all'")
		} else if strings.HasPrefix(field, "include:") {
			if ok, res, err := r.includeField(result, field); ok {
				return res, err
			}
		} else if strings.HasPrefix(field, "a") {
			if ok, res, err := r.aField(result, field, domain); ok {
				return res, err
			}
		} else if strings.HasPrefix(field, "mx") {
			if ok, res, err := r.mxField(result, field, domain); ok {
				return res, err
			}
		} else if strings.HasPrefix(field, "ip4:") || strings.HasPrefix(field, "ip6:") {
			if ok, res, err := r.ipField(result, field); ok {
				return res, err
			}
		} else if strings.HasPrefix(field, "ptr") {
			if ok, res, err := r.ptrField(result, field, domain); ok {
				return res, err
			}
		} else if strings.HasPrefix(field, "exists") {
			return Neutral, fmt.Errorf("'exists' not supported")
		} else if strings.HasPrefix(field, "exp=") {
			return Neutral, fmt.Errorf("'exp' not supported")
		} else if strings.HasPrefix(field, "redirect=") {
			// https://tools.ietf.org/html/rfc7208#section-6.1
			result, err := r.Check(field[len("redirect="):])
			if result == None {
				result = PermError
			}
			return result, err
		} else {
			// http://www.openspf.org/SPF_Record_Syntax
			return PermError, fmt.Errorf("unknown field %q", field)
		}
	}

	// Got to the end of the evaluation without a result => Neutral.
	// https://tools.ietf.org/html/rfc7208#section-4.7
	return Neutral, nil
}

// getDNSRecord gets TXT records from the given domain, and returns the SPF
// (if any).  Note that at most one SPF is allowed per a given domain:
// https://tools.ietf.org/html/rfc7208#section-3
// https://tools.ietf.org/html/rfc7208#section-3.2
// https://tools.ietf.org/html/rfc7208#section-4.5
func getDNSRecord(domain string) (string, error) {
	txts, err := lookupTXT(domain)
	if err != nil {
		return "", err
	}

	for _, txt := range txts {
		if strings.HasPrefix(txt, "v=spf1 ") {
			return txt, nil
		}

		// An empty record is explicitly allowed:
		// https://tools.ietf.org/html/rfc7208#section-4.5
		if txt == "v=spf1" {
			return txt, nil
		}
	}

	return "", nil
}

func isTemporary(err error) bool {
	derr, ok := err.(*net.DNSError)
	return ok && derr.Temporary()
}

// ipField processes an "ip" field.
func (r *resolution) ipField(res Result, field string) (bool, Result, error) {
	fip := field[4:]
	if strings.Contains(fip, "/") {
		_, ipnet, err := net.ParseCIDR(fip)
		if err != nil {
			return true, PermError, err
		}
		if ipnet.Contains(r.ip) {
			return true, res, fmt.Errorf("matched %v", ipnet)
		}
	} else {
		ip := net.ParseIP(fip)
		if ip == nil {
			return true, PermError, fmt.Errorf("invalid ipX value")
		}
		if ip.Equal(r.ip) {
			return true, res, fmt.Errorf("matched %v", ip)
		}
	}

	return false, "", nil
}

// ptrField processes a "ptr" field.
func (r *resolution) ptrField(res Result, field, domain string) (bool, Result, error) {
	// Extract the domain if the field is in the form "ptr:domain"
	if len(field) >= 4 {
		domain = field[4:]

	}

	if r.ipNames == nil {
		r.count++
		n, err := lookupAddr(r.ip.String())
		if err != nil {
			// https://tools.ietf.org/html/rfc7208#section-5
			if isTemporary(err) {
				return true, TempError, err
			}
			return false, "", err
		}
		r.ipNames = n
	}

	for _, n := range r.ipNames {
		if strings.HasSuffix(n, domain+".") {
			return true, res, fmt.Errorf("matched ptr:%s", domain)
		}
	}

	return false, "", nil
}

// includeField processes an "include" field.
func (r *resolution) includeField(res Result, field string) (bool, Result, error) {
	// https://tools.ietf.org/html/rfc7208#section-5.2
	incdomain := field[len("include:"):]
	ir, err := r.Check(incdomain)
	switch ir {
	case Pass:
		return true, res, err
	case Fail, SoftFail, Neutral:
		return false, ir, err
	case TempError:
		return true, TempError, err
	case PermError, None:
		return true, PermError, err
	}

	return false, "", fmt.Errorf("This should never be reached")

}

func ipMatch(ip, tomatch net.IP, mask int) (bool, error) {
	if mask >= 0 {
		_, ipnet, err := net.ParseCIDR(fmt.Sprintf("%s/%d", tomatch.String(), mask))
		if err != nil {
			return false, err
		}
		if ipnet.Contains(ip) {
			return true, fmt.Errorf("%v", ipnet)
		}
		return false, nil
	} else {
		if ip.Equal(tomatch) {
			return true, fmt.Errorf("%v", tomatch)
		}
		return false, nil
	}
}

var aRegexp = regexp.MustCompile("a(:([^/]+))?(/(.+))?")
var mxRegexp = regexp.MustCompile("mx(:([^/]+))?(/(.+))?")

func domainAndMask(re *regexp.Regexp, field, domain string) (string, int, error) {
	var err error
	mask := -1
	if groups := re.FindStringSubmatch(field); groups != nil {
		if groups[2] != "" {
			domain = groups[2]
		}
		if groups[4] != "" {
			mask, err = strconv.Atoi(groups[4])
			if err != nil {
				return "", -1, fmt.Errorf("error parsing mask")
			}
		}
	}

	return domain, mask, nil
}

// aField processes an "a" field.
func (r *resolution) aField(res Result, field, domain string) (bool, Result, error) {
	// https://tools.ietf.org/html/rfc7208#section-5.3
	domain, mask, err := domainAndMask(aRegexp, field, domain)
	if err != nil {
		return true, PermError, err
	}

	r.count++
	ips, err := lookupIP(domain)
	if err != nil {
		// https://tools.ietf.org/html/rfc7208#section-5
		if isTemporary(err) {
			return true, TempError, err
		}
		return false, "", err
	}
	for _, ip := range ips {
		ok, err := ipMatch(r.ip, ip, mask)
		if ok {
			return true, res, fmt.Errorf("matched 'a' (%v)", err)
		} else if err != nil {
			return true, PermError, err
		}
	}

	return false, "", nil
}

// mxField processes an "mx" field.
func (r *resolution) mxField(res Result, field, domain string) (bool, Result, error) {
	// https://tools.ietf.org/html/rfc7208#section-5.4
	domain, mask, err := domainAndMask(mxRegexp, field, domain)
	if err != nil {
		return true, PermError, err
	}

	r.count++
	mxs, err := lookupMX(domain)
	if err != nil {
		// https://tools.ietf.org/html/rfc7208#section-5
		if isTemporary(err) {
			return true, TempError, err
		}
		return false, "", err
	}
	mxips := []net.IP{}
	for _, mx := range mxs {
		r.count++
		ips, err := lookupIP(mx.Host)
		if err != nil {
			// https://tools.ietf.org/html/rfc7208#section-5
			if isTemporary(err) {
				return true, TempError, err
			}
			return false, "", err
		}
		mxips = append(mxips, ips...)
	}
	for _, ip := range mxips {
		ok, err := ipMatch(r.ip, ip, mask)
		if ok {
			return true, res, fmt.Errorf("matched 'mx' (%v)", err)
		} else if err != nil {
			return true, PermError, err
		}
	}

	return false, "", nil
}