pax_global_header00006660000000000000000000000064131465705300014516gustar00rootroot0000000000000052 comment=33aa9857eff1c5f0694c52ce6011daf5c3e3c67e golang-blitiri-go-spf-0.0+git20170821.0.33aa985/000077500000000000000000000000001314657053000202075ustar00rootroot00000000000000golang-blitiri-go-spf-0.0+git20170821.0.33aa985/LICENSE000066400000000000000000000022041314657053000212120ustar00rootroot00000000000000 Licensed under the MIT licence, which is reproduced below (from https://opensource.org/licenses/MIT). ----- Copyright (c) 2016 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. golang-blitiri-go-spf-0.0+git20170821.0.33aa985/README.md000066400000000000000000000015241314657053000214700ustar00rootroot00000000000000 # blitiri.com.ar/go/spf [![GoDoc](https://godoc.org/blitiri.com.ar/go/spf?status.svg)](https://godoc.org/blitiri.com.ar/go/spf) [spf](https://godoc.org/blitiri.com.ar/go/spf) is an open source implementation of the Sender Policy Framework (SPF) in Go. It is used by the [chasquid](https://blitiri.com.ar/p/chasquid/) SMTP server. ## Status The API should be considered stable. Major version changes will be announced to the mailing list (details below). Branch v1 will only have backwards-compatible changes made to it. There are no plans for v2 at the moment. ## Contact If you have any questions, comments or patches please send them to the mailing list, chasquid@googlegroups.com. To subscribe, send an email to chasquid+subscribe@googlegroups.com. You can also browse the [archives](https://groups.google.com/forum/#!forum/chasquid). golang-blitiri-go-spf-0.0+git20170821.0.33aa985/spf.go000066400000000000000000000253261314657053000213360ustar00rootroot00000000000000// 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 } golang-blitiri-go-spf-0.0+git20170821.0.33aa985/spf_test.go000066400000000000000000000110221314657053000223610ustar00rootroot00000000000000package spf import ( "flag" "fmt" "net" "os" "testing" ) var txtResults = map[string][]string{} var txtErrors = map[string]error{} func LookupTXT(domain string) (txts []string, err error) { return txtResults[domain], txtErrors[domain] } var mxResults = map[string][]*net.MX{} var mxErrors = map[string]error{} func LookupMX(domain string) (mxs []*net.MX, err error) { return mxResults[domain], mxErrors[domain] } var ipResults = map[string][]net.IP{} var ipErrors = map[string]error{} func LookupIP(host string) (ips []net.IP, err error) { return ipResults[host], ipErrors[host] } var addrResults = map[string][]string{} var addrErrors = map[string]error{} func LookupAddr(host string) (addrs []string, err error) { return addrResults[host], addrErrors[host] } func TestMain(m *testing.M) { lookupTXT = LookupTXT lookupMX = LookupMX lookupIP = LookupIP lookupAddr = LookupAddr flag.Parse() os.Exit(m.Run()) } var ip1110 = net.ParseIP("1.1.1.0") var ip1111 = net.ParseIP("1.1.1.1") var ip6666 = net.ParseIP("2001:db8::68") var ip6660 = net.ParseIP("2001:db8::0") func TestBasic(t *testing.T) { cases := []struct { txt string res Result }{ {"", None}, {"blah", None}, {"v=spf1", Neutral}, {"v=spf1 ", Neutral}, {"v=spf1 -", PermError}, {"v=spf1 all", Pass}, {"v=spf1 +all", Pass}, {"v=spf1 -all ", Fail}, {"v=spf1 ~all", SoftFail}, {"v=spf1 ?all", Neutral}, {"v=spf1 a ~all", SoftFail}, {"v=spf1 a/24", Neutral}, {"v=spf1 a:d1110/24", Pass}, {"v=spf1 a:d1110", Neutral}, {"v=spf1 a:d1111", Pass}, {"v=spf1 a:nothing/24", Neutral}, {"v=spf1 mx", Neutral}, {"v=spf1 mx/24", Neutral}, {"v=spf1 mx:a/montoto ~all", PermError}, {"v=spf1 mx:d1110/24 ~all", Pass}, {"v=spf1 ip4:1.2.3.4 ~all", SoftFail}, {"v=spf1 ip6:12 ~all", PermError}, {"v=spf1 ip4:1.1.1.1 -all", Pass}, {"v=spf1 ptr -all", Pass}, {"v=spf1 ptr:d1111 -all", Pass}, {"v=spf1 ptr:lalala -all", Pass}, {"v=spf1 blah", PermError}, } ipResults["d1111"] = []net.IP{ip1111} ipResults["d1110"] = []net.IP{ip1110} mxResults["d1110"] = []*net.MX{{"d1110", 5}, {"nothing", 10}} addrResults["1.1.1.1"] = []string{"lalala.", "domain.", "d1111."} for _, c := range cases { txtResults["domain"] = []string{c.txt} res, err := CheckHost(ip1111, "domain") if (res == TempError || res == PermError) && (err == nil) { t.Errorf("%q: expected error, got nil", c.txt) } if res != c.res { t.Errorf("%q: expected %q, got %q", c.txt, c.res, res) t.Logf("%q: error: %v", c.txt, err) } } } func TestIPv6(t *testing.T) { cases := []struct { txt string res Result }{ {"v=spf1 all", Pass}, {"v=spf1 a ~all", SoftFail}, {"v=spf1 a/24", Neutral}, {"v=spf1 a:d6660/24", Pass}, {"v=spf1 a:d6660", Neutral}, {"v=spf1 a:d6666", Pass}, {"v=spf1 a:nothing/24", Neutral}, {"v=spf1 mx:d6660/24 ~all", Pass}, {"v=spf1 ip6:2001:db8::68 ~all", Pass}, {"v=spf1 ip6:2001:db8::1/24 ~all", Pass}, {"v=spf1 ip6:2001:db8::1/100 ~all", Pass}, {"v=spf1 ptr -all", Pass}, {"v=spf1 ptr:d6666 -all", Pass}, {"v=spf1 ptr:sonlas6 -all", Pass}, } ipResults["d6666"] = []net.IP{ip6666} ipResults["d6660"] = []net.IP{ip6660} mxResults["d6660"] = []*net.MX{{"d6660", 5}, {"nothing", 10}} addrResults["2001:db8::68"] = []string{"sonlas6.", "domain.", "d6666."} for _, c := range cases { txtResults["domain"] = []string{c.txt} res, err := CheckHost(ip6666, "domain") if (res == TempError || res == PermError) && (err == nil) { t.Errorf("%q: expected error, got nil", c.txt) } if res != c.res { t.Errorf("%q: expected %q, got %q", c.txt, c.res, res) t.Logf("%q: error: %v", c.txt, err) } } } func TestNotSupported(t *testing.T) { cases := []string{ "v=spf1 exists:blah -all", "v=spf1 exp=blah -all", "v=spf1 a:%{o} -all", } for _, txt := range cases { txtResults["domain"] = []string{txt} res, err := CheckHost(ip1111, "domain") if res != Neutral { t.Errorf("%q: expected neutral, got %v", txt, res) t.Logf("%q: error: %v", txt, err) } } } func TestRecursion(t *testing.T) { txtResults["domain"] = []string{"v=spf1 include:domain ~all"} res, err := CheckHost(ip1111, "domain") if res != PermError { t.Errorf("expected permerror, got %v (%v)", res, err) } } func TestNoRecord(t *testing.T) { txtResults["d1"] = []string{""} txtResults["d2"] = []string{"loco", "v=spf2"} txtErrors["nospf"] = fmt.Errorf("no such domain") for _, domain := range []string{"d1", "d2", "d3", "nospf"} { res, err := CheckHost(ip1111, domain) if res != None { t.Errorf("expected none, got %v (%v)", res, err) } } }