diff --git a/Tests/spfcheckTest.php b/Tests/spfcheckTest.php new file mode 100644 index 0000000..ed4b0f6 --- /dev/null +++ b/Tests/spfcheckTest.php @@ -0,0 +1,208 @@ + + */ + +/** Test the spfcheck tools + */ +class spfcheckTest extends PHPUnit_Framework_TestCase +{ + public function test_getRecords_NoSPF () + { + $this->expectException ("Exception", + "Can not find a valid SPF TXT entry in DNS for domain ". + "'notfound.tester.fournier38.fr'", 403); + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("notfound.tester.fournier38.fr"); + } + + public function test_getRecords_SPFReject () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("reject.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("reject.spf.tester.fournier38.fr" => + array ("-all" => array ()))); + } + + public function test_getRecords_Loop () + { + $this->expectException ("Exception", + "SPFCheck : Too much DNS requests (30 >= 30)", 500); + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("loop.spf.tester.fournier38.fr"); + $this->assertSame ($res, array ()); + } + + public function test_getRecords_Include_emptyInclude () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("includeempty.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("includeempty.spf.tester.fournier38.fr" => + array ("include:" => array (), "-all" => array ()))); + } + + public function test_getRecords_Redirect_emptyRedirect () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("redirectempty.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("redirectempty.spf.tester.fournier38.fr" => + array ("redirect=" => array (), "-all" => array ()))); + } + + public function test_getRecords_MX_emptyMX () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("mx.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("mx.spf.tester.fournier38.fr" => + array ("mx" => array (), "-all" => array ()))); + } + + public function test_getRecords_MX_validMX () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("mxvalid.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("mxvalid.spf.tester.fournier38.fr" => array ( + "mx:tester.fournier38.fr" => array ( + "2a01:e0a:234:3930::103", + "2a01:e0a:289:3090::206", + "82.64.55.197", + "82.64.75.195"), + "-all" => array ()))); + } + + public function test_getRecords_A_emptyA () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("a.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("a.spf.tester.fournier38.fr" => + array ("a" => array (), "-all" => array ()))); + } + + public function test_getRecords_A_validA () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("avalid.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("avalid.spf.tester.fournier38.fr" => array ( + "a:tester.fournier38.fr" => array ( + "2a01:e0a:234:3930::100", + "82.64.55.197",), + "-all" => array ()))); + } + + public function test_getRecords_IP4_emptyIP4 () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("ip4empty.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("ip4empty.spf.tester.fournier38.fr" => array ( + "ip4:" => array (), + "-all" => array ()))); + } + + public function test_getRecords_IP4_invalidIP4 () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("ip4invalid.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("ip4invalid.spf.tester.fournier38.fr" => array ( + "ip4:0::1" => array (), + "-all" => array ()))); + } + + public function test_getRecords_IP4_validIP4 () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("ip4valid.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("ip4valid.spf.tester.fournier38.fr" => array ( + "ip4:192.168.1.1" => array ("192.168.1.1"), + "-all" => array ()))); + } + + public function test_getRecords_IP6_emptyIP6 () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("ip6empty.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("ip6empty.spf.tester.fournier38.fr" => array ( + "ip6:" => array (), + "-all" => array ()))); + } + + public function test_getRecords_IP6_invalidIP6 () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("ip6invalid.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("ip6invalid.spf.tester.fournier38.fr" => array ( + "ip6:192.168.1.1" => array (), + "-all" => array ()))); + } + + public function test_getRecords_IP6_validIP6 () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("ip6valid.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("ip6valid.spf.tester.fournier38.fr" => array ( + "ip6:0::1" => array ("0::1"), + "-all" => array ()))); + } + + public function test_getRecords_PTR () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("ptrvalid.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("ptrvalid.spf.tester.fournier38.fr" => array ( + "ptr" => array (), + "-all" => array ()))); + } + + public function test_getRecords_All_Multiple () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("allmultiple.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("allmultiple.spf.tester.fournier38.fr" => array ( + "+all" => array (), + "-all" => array ()))); + } + + public function test_getRecords_All_NotEnd () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("allnotend.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("allnotend.spf.tester.fournier38.fr" => array ( + "+all" => array (), + "mx" => array ()))); + } + + public function test_getRecords_All_NotSet () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("allnotset.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("allnotset.spf.tester.fournier38.fr" => array ( + "mx" => array ()))); + } + + public function test_getRecords_Unknown () + { + $spfcheck = new spfcheck (); + $res = $spfcheck->getRecords ("unknown.spf.tester.fournier38.fr"); + $this->assertSame ($res, + array ("unknown.spf.tester.fournier38.fr" => array ( + "unknown" => array (), + "-all" => array ()))); + } +} diff --git a/spfcheck.php b/spfcheck.php new file mode 100644 index 0000000..8888352 --- /dev/null +++ b/spfcheck.php @@ -0,0 +1,472 @@ + + */ + +require ("domframework/ipaddresses.php"); + +/** This class allow to get a SPF record for a domain and check an IP against + * the rules set in SPF record. + * It also says in which rule the IP match + * RFC 7208 + * SPF Format : + * v=spf1 (ip4:[0-9.]+(/\d+)|ip6:[0-9a-f:]+(/\d+)|mx(:\S+)| + * a(:\S+)| + * redirect=\S+|include:\S+) + */ +class spfcheck +{ + //////////////////// + // PROPERTIES // + //////////////////// + /** The stack of errors detected + */ + private $errors = array (); + /** The [+-~]all parameter first get + */ + private $catchAll = ""; + /** The domain catchAll + */ + private $catchAllDomain = ""; + /** The rule matching the search + */ + private $matchRule = ""; + /** The IP records get from SPF record + */ + private $ipRecords = array (); + /** Store all the DNS requests done + */ + private $dnsRequests = array (); + + /** Set the DNS maximum number of requests + */ + const dnsRequestsMax = 30; + + //////////////////////// + // PUBLIC METHODS // + //////////////////////// + /** Get all the IP entries set in all the blocks of the SPF for provided + * domain. Manage all the redirections, and get extract all the MX, IP4, IP6 + * content. + * Set also the $this->errors and $this->catchAll properties available with + * the associated getters + * @param string $domain The domain to check + * @return array (The netmasks to match, the last all) + */ + public function getRecords ($domain) + // {{{ + { + $this->errors = array (); + $this->dnsRequests = array (); + $this->catchAll = ""; + $this->ipRecords = $this->getRecordsRecurse ($domain); + if ($this->catchAll === "") + $this->errors[$domain] = dgettext ("domframework", + "No catch all defined for the domain"); + return $this->ipRecords; + } + // }}} + + /** Try to match the provided IP address against the $domain SPF record to + * be get + * @param string $domain The domain to check + * @param string $ip The IPv4 or IPv6 address to check against the SPF record + * @return string PASS/FAIL/SOFTFAIL + */ + public function ipCheckToSPF ($domain, $ip) + // {{{ + { + $ips = $this->getRecords ($domain); + if (filter_var ($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) + $ipType = "ipv4"; + elseif (filter_var ($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) + $ipType = "ipv6"; + else + throw new \Exception (dgettext ("domframework", + "SFPCheck : Invalid IP address provided : Not Ipv4 neither IPv6"), 403); + $ipaddresses = new ipaddresses (); + foreach ($ips as $key => $sub) + { + foreach ($sub as $part => $spfips) + { + foreach ($spfips as $ipToTest) + { + @list ($ipToTest, $mask) = explode ("/", $ipToTest, 2); + if ($ipType === "ipv4") + { + if (! filter_var ($ipToTest, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) + continue; + if ($mask === null) + $mask = "32"; + } + else + { + if (! filter_var ($ipToTest, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) + continue; + if ($mask === null) + $mask = "128"; + } + if ($ipaddresses->ipInNetwork ($ip, $ipToTest, $mask)) + { + $this->matchRule = "$key/$part"; + return "PASS"; + } + } + } + } + if ($this->catchAll === "-all") + { + $this->matchRule = "$this->catchAllDomain/$this->catchAll"; + return "FAIL"; + } + if ($this->catchAll === "~all") + { + $this->matchRule = "$this->catchAllDomain/$this->catchAll"; + return "SOFTFAIL"; + } + } + // }}} + + ///////////////// + // GETTERS // + ///////////////// + /** Get the errors detected when reading the SPF record + */ + public function getErrors () + // {{{ + { + return $this->errors; + } + // }}} + + /** Get the DNS requests done to get the SPF records + */ + public function getDNSRequests () + // {{{ + { + return $this->dnsRequests; + } + // }}} + + /** Get the number of DNS queries + */ + public function getDNSRequestNumber () + // {{{ + { + return count ($this->dnsRequests); + } + // }}} + + /** Get default case (if set) + */ + public function getDefaultCase () + // {{{ + { + return $this->catchAll; + } + // }}} + + /** Get the matching rule when testing an IP against a SPF domain record + */ + public function getMatchRule () + // {{{ + { + return $this->matchRule; + } + // }}} + + /** Get the IPs from the SPF records + */ + public function getIpRecords () + // {{{ + { + return $this->ipRecords; + } + // }}} + + ///////////////////////// + // PRIVATE METHODS // + ///////////////////////// + /** Get all the IP entries set in all the blocks of the SPF for provided + * domain. Manage all the redirections, and get extract all the MX, IP4, IP6 + * content. + * Set also the $this->errors and $this->catchAll properties available with + * the associated getters + * Recursive, can be called by itself + * @param string $domain The domain to check + * @return array (The netmasks to match, the last all) + */ + private function getRecordsRecurse ($domain) + // {{{ + { + $records = array (); + $localAll = ""; + // TODO : Catch timeouts ! + foreach ($this->dns_get_record ($domain, DNS_TXT, $domain) as $record) + { + if (substr ($record, 0, 7) !== "v=spf1 ") + continue; + // Do not allow more than 1 SPF record by domain + if (key_exists ($domain, $records)) + $this->errors[$domain][] = sprintf (dgettext ("domframework", + "More than one SPF record for domain '%s'"), $domain); + $records[$domain] = $record; + } + if (empty ($records)) + throw new \Exception (sprintf (dgettext ("domframework", + "Can not find a valid SPF TXT entry in DNS for domain '%s'"), $domain), + 403); + $ips = array (); + foreach ($records as $domain => $record) + { + $split = preg_split ("#\s+#", $record); + foreach ($split as $nb => $part) + { + if ($part === "v=spf1") + continue; + // "redirect=" part + $ips[$domain][$part] = array (); + if (stripos ($part, "redirect=") === 0) + // {{{ + { + $ext = substr ($part, 9); + if (! is_string ($ext) || trim ($ext) === "") + { + $this->errors[$domain][$part] = sprintf (dgettext ("domframework", + "Invalid redirect set form domain '%s' : empty"), $domain); + continue; + } + $ips = $ips + $this->getRecordsRecurse ($ext); + } + // }}} + // "include:" part + elseif (stripos ($part, "include:") === 0) + // {{{ + { + $ext = substr ($part, 8); + if (! is_string ($ext) || trim ($ext) === "") + { + $this->errors[$domain][$part] = sprintf (dgettext ("domframework", + "Invalid include set form domain '%s' : empty"), $domain); + continue; + } + $ips = $ips + $this->getRecordsRecurse ($ext); + } + // }}} + // "mx:" / "mx" part + elseif (stripos ($part, "mx:") === 0 || strtolower ($part) === "mx") + // {{{ + { + $partWithDomain = $part; + if ($partWithDomain === "mx") + $partWithDomain = "mx:$domain"; + $ext = substr ($partWithDomain, 3); + if (! is_string ($ext) || trim ($ext) === "") + { + $this->errors[$domain][$part] = sprintf (dgettext ("domframework", + "Invalid mx set form domain '%s' : empty"), $domain); + continue; + } + foreach ($this->dns_get_record ($ext, DNS_MX, $domain) as $record) + { + foreach ($this->dns_get_record ($record, DNS_A | DNS_AAAA, $domain) + as $ip) + { + $ips[$domain][$part][] = $ip; + } + } + sort ($ips[$domain][$part]); + } + // }}} + // "ip4:" part + elseif (stripos ($part, "ip4:") === 0) + // {{{ + { + $ext = substr ($part, 4); + if (! is_string ($ext) || trim ($ext) === "") + { + $this->errors[$domain][$part] = sprintf (dgettext ("domframework", + "Invalid ip4 set form domain '%s' : empty"), $domain); + continue; + } + @list ($ip, $mask) = explode ("/", $ext); + $mask = ($mask === null) ? $mask = "" : $mask = "/$mask"; + if (filter_var ($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) + { + $this->errors[$domain][$part] = sprintf (dgettext ("domframework", + "Invalid ip4 set for domain '%s' : Not a valid IPv4 '%s'"), + $domain, $ext); + continue; + } + $ips[$domain][$part][] = $ip.$mask; + } + // }}} + // "ip6:" part + elseif (stripos ($part, "ip6:") === 0) + // {{{ + { + $ext = substr ($part, 4); + if (! is_string ($ext) || trim ($ext) === "") + { + $this->errors[$domain][$part] = sprintf (dgettext ("domframework", + "Invalid ip6 set form domain '%s' : empty"), $domain); + continue; + } + @list ($ip, $mask) = explode ("/", $ext); + $mask = ($mask === null) ? $mask = "" : $mask = "/$mask"; + if (filter_var ($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) + { + $this->errors[$domain][$part] = sprintf (dgettext ("domframework", + "Invalid ip6 set for domain '%s' : Not a valid IPv6 '%s'"), + $domain, $ext); + continue; + } + $ips[$domain][$part][] = $ip.$mask; + } + // }}} + // "ptr:" MUST NOT BE USED + elseif (stripos ($part, "ptr:") === 0 || strtolower ($part) === "ptr") + // {{{ + { + $this->errors[$domain][$part] = sprintf (dgettext ("domframework", + "Invalid ptr set for domain '%s' : PTR must not be used anymore ". + "(see RFC7208)"), $domain, $part); + continue; + } + // }}} + // "a:" part + elseif (stripos ($part, "a:") === 0 || strtolower ($part) === "a") + // {{{ + { + $partWithDomain = $part; + if ($partWithDomain === "a") + $partWithDomain = "a:$domain"; + $ext = substr ($partWithDomain, 2); + if (! is_string ($ext) || trim ($ext) === "") + { + $this->errors[$domain][$part] = sprintf (dgettext ("domframework", + "Invalid A set form domain '%s' : empty"), $domain); + continue; + } + foreach ($this->dns_get_record ($ext, DNS_A | DNS_AAAA, $domain) as + $record) + { + $ips[$domain][$part][] = $record; + } + sort ($ips[$domain][$part]); + } + // }}} + // "-all" / "~all" / "+all" part + elseif (strtolower ($part) === "-all" || + strtolower ($part) === "~all" || + strtolower ($part) === "+all") + // {{{ + { + $ips[$domain][$part] = array (); + if ($localAll !== "") + { + $this->errors [$domain][$part] = sprintf (dgettext ("domframework", + "Multiple 'all' definitions for domain '%s'"), $domain); + continue; + } + if ($nb < count ($split) -1) + { + $this->errors [$domain][$part] = sprintf (dgettext ("domframework", + "'all' must be the last part of the record for domain '%s'"), + $domain); + } + $localAll = $part; + $this->catchAll = $part; + $this->catchAllDomain = $domain; + } + // }}} + else + { + $this->errors [$domain][$part] = sprintf (dgettext ("domframework", + "Unknown record part for domain '%s' : '%s'"), $domain, $part); + } + } + } + return $ips; + } + // }}} + + /** Get the requested hostname and get the type + * @param string $hostname The hostname to get + * @param integer $type The type to get + * @param string $domain The domain to impact the errors + * @return array (array (ip (for A), target (for TXT)); + */ + private function dns_get_record ($hostname, $type, $domain) + // {{{ + { + $typeStr = ""; + switch ($type) + { + case DNS_TXT: $typeStr = "TXT"; break; + case DNS_MX: $typeStr = "MX"; break; + case DNS_A | DNS_AAAA: $typeStr = "A | AAAA"; break; + default: throw new \Exception (dgettext ("domframework", + "SPFCheck : Invalid type for DNS get record"), 500); + } + if (count ($this->dnsRequests) >= self::dnsRequestsMax) + throw new \Exception (sprintf (dgettext ("domframework", + "SPFCheck : Too much DNS requests (%d >= %d)"), + count ($this->dnsRequests), self::dnsRequestsMax), 500); + $this->dnsRequests[] = "$hostname, $typeStr"; + $res = array (); + if ($type === DNS_TXT) + { + foreach (dns_get_record ($hostname, DNS_TXT) as $record) + { + if (! isset ($record["txt"])) + { + $this->errors[$somain][] = sprintf (dgettext ("domframework", + "No TXT record for domain '%s'"), $domain); + continue; + } + $res[] = $record["txt"]; + } + } + elseif ($type === DNS_MX) + { + foreach (dns_get_record ($hostname, DNS_MX) as $record) + { + if (! isset ($record["target"])) + { + $this->errors[$domain][] = sprintf (dgettext ("domframework", + "No MX record for domain '%s' : '%s' not found"), $domain, + $hostname); + continue; + } + $res[] = $record["target"]; + } + } + elseif ($type === DNS_A | DNS_AAAA) + { + $records = dns_get_record ($hostname, DNS_A | DNS_AAAA); + if (empty ($records)) + $this->errors[$domain][] = sprintf (dgettext ("domframework", + "No IP record for domain '%s' : '%s' not found"), $domain, + $hostname); + foreach ($records as $record) + { + if (! isset ($record["ip"]) && ! isset ($record["ipv6"])) + { + $this->errors[$domain][] = sprintf (dgettext ("domframework", + "No IP record for domain '%s' : '%s' not IPv4 nor IPv6"), $domain, + $hostname); + continue; + } + $ip = isset ($record["ip"]) ? $record["ip"] : $record["ipv6"]; + $res[] = $ip; + } + } + else + throw new \Exception ("Can not get unknown type : $type"); + return $res; + } + // }}} +} +