*/ 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 (); /** Store the number of DNS requests */ private $dnsCounter = 0; /** 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->dnsCounter = 0; $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"; } if ($this->catchAll === "?all") { $this->matchRule = "$this->catchAllDomain/$this->catchAll"; return "NEUTRAL"; } } // }}} ///////////////// // 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; } // }}} /** Get the DNS values set for the provided $domain/$part(/$entity) * @param string $search The search entity * @return array The array of result from DNS * @return false if not exists */ public function getDNSEntries ($search) { if (! key_exists ($search, $this->dnsRequests)) return false; return $this->dnsRequests[$search]; } ///////////////////////// // 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/$part") as $record) { foreach ($this->dns_get_record ($record, DNS_A | DNS_AAAA, "$domain/$part/$record") 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) : Skip it"), $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/$part") as $record) { $ips[$domain][$part][] = $record; } sort ($ips[$domain][$part]); } // }}} // "-all" / "~all" / "+all" part elseif (strtolower ($part) === "-all" || 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 ($this->dnsCounter >= self::dnsRequestsMax) throw new \Exception (sprintf (dgettext ("domframework", "SPFCheck : Too much DNS requests (%d >= %d)"), $this->dnsCounter, self::dnsRequestsMax), 500); $this->dnsCounter++; $res = array (); if ($type === DNS_TXT) { foreach (dns_get_record ($hostname, DNS_TXT) as $record) { if (! isset ($record["txt"])) { $this->errors[$domain][] = 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"); $this->dnsRequests[$domain] = $res; return $res; } // }}} }