Skip to content

Commit

Permalink
Reimplement DNSCheckValidation (#250)
Browse files Browse the repository at this point in the history
* Reimplement DNSCheckValidation to utilise get_dns_record and extend support for detection of Null MX records (RFC7505) and reserved, mDNS and private namespaces (RFC2606 & RFC6762)

* Change valid email tests to use github.com instead of example.com as example.com now (correctly) fails the tests due to the Null MX case

* Tweak to pass CI psalm issue

* Encapsulate dns check code. Simplify local and reserved domain checks. Switch test domain

* Set PHPDoc return type

* Use strict comparisons
  • Loading branch information
BenHarris committed Aug 8, 2020
1 parent feba958 commit 840d560
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 20 deletions.
9 changes: 9 additions & 0 deletions src/Exception/DomainAcceptsNoMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Egulias\EmailValidator\Exception;

class DomainAcceptsNoMail extends InvalidEmail
{
const CODE = 154;
const REASON = 'Domain accepts no mail (Null MX, RFC7505)';
}
9 changes: 9 additions & 0 deletions src/Exception/LocalOrReservedDomain.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Egulias\EmailValidator\Exception;

class LocalOrReservedDomain extends InvalidEmail
{
const CODE = 153;
const REASON = 'Local, mDNS or reserved domain (RFC2606, RFC6762)';
}
113 changes: 102 additions & 11 deletions src/Validation/DNSCheckValidation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use Egulias\EmailValidator\EmailLexer;
use Egulias\EmailValidator\Exception\InvalidEmail;
use Egulias\EmailValidator\Exception\LocalOrReservedDomain;
use Egulias\EmailValidator\Exception\DomainAcceptsNoMail;
use Egulias\EmailValidator\Warning\NoDNSMXRecord;
use Egulias\EmailValidator\Exception\NoDNSRecord;

Expand All @@ -19,6 +21,12 @@ class DNSCheckValidation implements EmailValidation
*/
private $error;

/**
* @var array
*/
private $mxRecords = [];


public function __construct()
{
if (!function_exists('idn_to_ascii')) {
Expand All @@ -36,7 +44,40 @@ public function isValid($email, EmailLexer $emailLexer)
$host = substr($email, $lastAtPos + 1);
}

return $this->checkDNS($host);
// Get the domain parts
$hostParts = explode('.', $host);

// Reserved Top Level DNS Names (https://tools.ietf.org/html/rfc2606#section-2),
// mDNS and private DNS Namespaces (https://tools.ietf.org/html/rfc6762#appendix-G)
$reservedTopLevelDnsNames = [
// Reserved Top Level DNS Names
'test',
'example',
'invalid',
'localhost',

// mDNS
'local',

// Private DNS Namespaces
'intranet',
'internal',
'private',
'corp',
'home',
'lan',
];

$isLocalDomain = count($hostParts) <= 1;
$isReservedTopLevel = in_array($hostParts[(count($hostParts) - 1)], $reservedTopLevelDnsNames, true);

// Exclude reserved top level DNS names
if ($isLocalDomain || $isReservedTopLevel) {
$this->error = new LocalOrReservedDomain();
return false;
}

return $this->checkDns($host);
}

public function getError()
Expand All @@ -54,24 +95,74 @@ public function getWarnings()
*
* @return bool
*/
protected function checkDNS($host)
protected function checkDns($host)
{
$variant = INTL_IDNA_VARIANT_2003;
if ( defined('INTL_IDNA_VARIANT_UTS46') ) {
if (defined('INTL_IDNA_VARIANT_UTS46')) {
$variant = INTL_IDNA_VARIANT_UTS46;
}

$host = rtrim(idn_to_ascii($host, IDNA_DEFAULT, $variant), '.') . '.';

$Aresult = true;
$MXresult = checkdnsrr($host, 'MX');
return $this->validateDnsRecords($host);
}

if (!$MXresult) {
$this->warnings[NoDNSMXRecord::CODE] = new NoDNSMXRecord();
$Aresult = checkdnsrr($host, 'A') || checkdnsrr($host, 'AAAA');
if (!$Aresult) {
$this->error = new NoDNSRecord();

/**
* Validate the DNS records for given host.
*
* @param string $host A set of DNS records in the format returned by dns_get_record.
*
* @return bool True on success.
*/
private function validateDnsRecords($host)
{
// Get all MX, A and AAAA DNS records for host
$dnsRecords = dns_get_record($host, DNS_MX + DNS_A + DNS_AAAA);


// No MX, A or AAAA DNS records
if (empty($dnsRecords)) {
$this->error = new NoDNSRecord();
return false;
}

// For each DNS record
foreach ($dnsRecords as $dnsRecord) {
if (!$this->validateMXRecord($dnsRecord)) {
return false;
}
}
return $MXresult || $Aresult;

// No MX records (fallback to A or AAAA records)
if (empty($this->mxRecords)) {
$this->warnings[NoDNSMXRecord::CODE] = new NoDNSMXRecord();
}

return true;
}

/**
* Validate an MX record
*
* @param array $dnsRecord Given DNS record.
*
* @return bool True if valid.
*/
private function validateMxRecord($dnsRecord)
{
if ($dnsRecord['type'] !== 'MX') {
return true;
}

// "Null MX" record indicates the domain accepts no mail (https://tools.ietf.org/html/rfc7505)
if (empty($dnsRecord['target']) || $dnsRecord['target'] === '.') {
$this->error = new DomainAcceptsNoMail();
return false;
}

$this->mxRecords[] = $dnsRecord;

return true;
}
}
64 changes: 55 additions & 9 deletions tests/EmailValidator/Validation/DNSCheckValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use Egulias\EmailValidator\EmailLexer;
use Egulias\EmailValidator\Exception\NoDNSRecord;
use Egulias\EmailValidator\Exception\LocalOrReservedDomain;
use Egulias\EmailValidator\Exception\DomainAcceptsNoMail;
use Egulias\EmailValidator\Validation\DNSCheckValidation;
use Egulias\EmailValidator\Warning\NoDNSMXRecord;
use PHPUnit\Framework\TestCase;
Expand All @@ -14,22 +16,44 @@ public function validEmailsProvider()
{
return [
// dot-atom
['Abc@example.com'],
['ABC@EXAMPLE.COM'],
['Abc.123@example.com'],
['user+mailbox/department=shipping@example.com'],
['!#$%&\'*+-/=?^_`.{|}~@example.com'],
['Abc@ietf.org'],
['ABC@ietf.org'],
['Abc.123@ietf.org'],
['user+mailbox/department=shipping@ietf.org'],
['!#$%&\'*+-/=?^_`.{|}~@ietf.org'],

// quoted string
['"Abc@def"@example.com'],
['"Fred\ Bloggs"@example.com'],
['"Joe.\\Blow"@example.com'],
['"Abc@def"@ietf.org'],
['"Fred\ Bloggs"@ietf.org'],
['"Joe.\\Blow"@ietf.org'],

// unicide
['ñandu.cl'],
];
}

public function localOrReservedEmailsProvider()
{
return [
// Reserved Top Level DNS Names
['test'],
['example'],
['invalid'],
['localhost'],

// mDNS
['local'],

// Private DNS Namespaces
['intranet'],
['internal'],
['private'],
['corp'],
['home'],
['lan'],
];
}

/**
* @dataProvider validEmailsProvider
*/
Expand All @@ -45,13 +69,35 @@ public function testInvalidDNS()
$this->assertFalse($validation->isValid("example@invalid.example.com", new EmailLexer()));
}

/**
* @dataProvider localOrReservedEmailsProvider
*/
public function testLocalOrReservedDomainError($localOrReservedEmails)
{
$validation = new DNSCheckValidation();
$expectedError = new LocalOrReservedDomain();
$validation->isValid($localOrReservedEmails, new EmailLexer());
$this->assertEquals($expectedError, $validation->getError());
}

public function testDomainAcceptsNoMailError()
{
$validation = new DNSCheckValidation();
$expectedError = new DomainAcceptsNoMail();
$isValidResult = $validation->isValid("nullmx@example.com", new EmailLexer());
$this->assertEquals($expectedError, $validation->getError());
$this->assertFalse($isValidResult);
}

/*
public function testDNSWarnings()
{
$validation = new DNSCheckValidation();
$expectedWarnings = [NoDNSMXRecord::CODE => new NoDNSMXRecord()];
$validation->isValid("example@invalid.example.com", new EmailLexer());
$this->assertEquals($expectedWarnings, $validation->getWarnings());
}
*/

public function testNoDNSError()
{
Expand All @@ -60,4 +106,4 @@ public function testNoDNSError()
$validation->isValid("example@invalid.example.com", new EmailLexer());
$this->assertEquals($expectedError, $validation->getError());
}
}
}

0 comments on commit 840d560

Please sign in to comment.