Files
DomFramework/spfcheck.php

473 lines
14 KiB
PHP

<?php
/** DomFramework
* @package domframework
* @author Dominique Fournier <dominique@fournier38.fr>
*/
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;
}
// }}}
}