575 lines
18 KiB
PHP
575 lines
18 KiB
PHP
<?php
|
|
/** DomFramework
|
|
* @package domframework
|
|
* @author Dominique Fournier <dominique@fournier38.fr>
|
|
* @license BSD
|
|
*/
|
|
|
|
//namespace Domframework;
|
|
|
|
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";
|
|
if ($part{0} === "-")
|
|
return "FAIL";
|
|
if ($part{0} === "~")
|
|
return "SOFTFAIL";
|
|
if ($part{0} === "?")
|
|
return "NEUTRAL";
|
|
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";
|
|
}
|
|
throw new \Exception (sprintf (dgettext ("domframework",
|
|
"SFPCheck : Can not determine the SPF result with params '%s' and '%s'"),
|
|
$domain, $ip), 403 );
|
|
}
|
|
// }}}
|
|
|
|
/////////////////
|
|
// 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 $this->dnsCounter;
|
|
}
|
|
// }}}
|
|
|
|
/** 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 ();
|
|
// Manage the sign in front of part
|
|
$sign = "";
|
|
if ($part{0} === "+" || $part{0} === "-" || $part{0} === "~" ||
|
|
$part{0} === "?")
|
|
{
|
|
$sign = $part[0];
|
|
$part = substr ($part, 1);
|
|
}
|
|
if (stripos ($part, "redirect=") === 0)
|
|
// {{{
|
|
{
|
|
if ($sign !== "")
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid redirect set for domain '%s' : can not be signed"),
|
|
$domain);
|
|
continue;
|
|
}
|
|
$ext = substr ($part, 9);
|
|
if (! is_string ($ext) || trim ($ext) === "")
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid redirect set for domain '%s' : empty"), $domain);
|
|
continue;
|
|
}
|
|
$ips = $ips + $this->getRecordsRecurse ($ext);
|
|
}
|
|
// }}}
|
|
// "include:" part
|
|
elseif (stripos ($part, "include:") === 0)
|
|
// {{{
|
|
{
|
|
if ($sign !== "")
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid include set for domain '%s' : can not be signed"),
|
|
$domain);
|
|
continue;
|
|
}
|
|
$ext = substr ($part, 8);
|
|
if (! is_string ($ext) || trim ($ext) === "")
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid include set for 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][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid mx set for 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][$sign.$part][] = $ip;
|
|
}
|
|
}
|
|
if (! isset ($ips[$domain][$sign.$part]))
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid mx set for domain '%s' : not available in DNS"),
|
|
$domain);
|
|
continue;
|
|
}
|
|
sort ($ips[$domain][$sign.$part]);
|
|
}
|
|
// }}}
|
|
// "ip4:" part
|
|
elseif (stripos ($part, "ip4:") === 0)
|
|
// {{{
|
|
{
|
|
$ext = substr ($part, 4);
|
|
if (! is_string ($ext) || trim ($ext) === "")
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid ip4 set for 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][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid ip4 set for domain '%s' : Not a valid IPv4 '%s'"),
|
|
$domain, $ext);
|
|
continue;
|
|
}
|
|
if ($mask !== "" &&
|
|
filter_var ($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) &&
|
|
substr ($mask, 1) < 16)
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid ip4 set for domain '%s' : Mask '%s' too wide"),
|
|
$domain, $mask);
|
|
continue;
|
|
}
|
|
$ips[$domain][$sign.$part][] = $ip.$mask;
|
|
}
|
|
// }}}
|
|
// "ip6:" part
|
|
elseif (stripos ($part, "ip6:") === 0)
|
|
// {{{
|
|
{
|
|
$ext = substr ($part, 4);
|
|
if (! is_string ($ext) || trim ($ext) === "")
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid ip6 set for 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][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid ip6 set for domain '%s' : Not a valid IPv6 '%s'"),
|
|
$domain, $ext);
|
|
continue;
|
|
}
|
|
if ($mask !== "" &&
|
|
filter_var ($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) &&
|
|
substr ($mask, 1) < 64)
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid ip6 set for domain '%s' : Mask '%s' too wide"),
|
|
$domain, $mask);
|
|
continue;
|
|
}
|
|
$ips[$domain][$sign.$part][] = $ip.$mask;
|
|
}
|
|
// }}}
|
|
// "ptr:" MUST NOT BE USED
|
|
elseif (stripos ($part, "ptr:") === 0 || strtolower ($part) === "ptr")
|
|
// {{{
|
|
{
|
|
$this->errors[$domain][$sign.$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][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid A set for domain '%s' : empty"), $domain);
|
|
continue;
|
|
}
|
|
foreach ($this->dns_get_record ($ext, DNS_A | DNS_AAAA,
|
|
"$domain/$part") as $record)
|
|
{
|
|
$ips[$domain][$sign.$part][] = $record;
|
|
}
|
|
if (! isset ($ips[$domain][$sign.$part]))
|
|
{
|
|
$this->errors[$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Invalid A set for domain '%s' : not available in DNS"), $domain);
|
|
continue;
|
|
}
|
|
sort ($ips[$domain][$sign.$part]);
|
|
}
|
|
// }}}
|
|
// "-all" / "~all" / "+all" part
|
|
elseif (strtolower ($part) === "all")
|
|
// {{{
|
|
{
|
|
$ips[$domain][$sign.$part] = array ();
|
|
if ($localAll !== "")
|
|
{
|
|
$this->errors [$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"Multiple 'all' definitions for domain '%s'"), $domain);
|
|
continue;
|
|
}
|
|
if ($nb < count ($split) -1)
|
|
{
|
|
$this->errors [$domain][$sign.$part] =
|
|
sprintf (dgettext ("domframework",
|
|
"'all' must be the last part of the record for domain '%s'"),
|
|
$domain);
|
|
}
|
|
$localAll = $part;
|
|
$this->catchAll = $sign.$part;
|
|
$this->catchAllDomain = $domain;
|
|
}
|
|
// }}}
|
|
else
|
|
{
|
|
$this->errors [$domain][$sign.$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 hostname '%s' found"), $hostname);
|
|
foreach ($records as $record)
|
|
{
|
|
if (! isset ($record["ip"]) && ! isset ($record["ipv6"]))
|
|
{
|
|
$this->errors[$domain][] = sprintf (dgettext ("domframework",
|
|
"No IP record for hostname '%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;
|
|
}
|
|
// }}}
|
|
}
|
|
|