Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions src/DNS/Validator/Name.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@

class Name extends Validator
{
private const array RECORD_TYPES_WITH_UNDERSCORE_IN_NAME = [Record::TYPE_SRV, Record::TYPE_TXT];
/**
* Record types whose owner name must be a valid host name, where the
* LDH rule applies (RFC 952, RFC 1123 section 2.1) and underscores are
* forbidden. Owner names of all other record types follow the general
* domain name rules (RFC 2181 section 11), where underscored labels
* (RFC 8552) are legal - e.g. DKIM '_domainkey' CNAME/TXT records.
*/
private const array RECORD_TYPES_WITH_HOSTNAME_OWNER = [Record::TYPE_A, Record::TYPE_AAAA];

public const int LABEL_MAX_LENGTH = 63;

Expand All @@ -20,13 +27,18 @@ class Name extends Validator

public const string FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE = 'Label must contain only alpha-numeric characters, hyphens and underscores, and cannot start or end with a hyphen';

public const string FAILURE_REASON_GENERAL = 'Name must be between 1 and 255 characters long, and contain only alpha-numeric characters and hyphens, and cannot start or end with a hyphen, and may contain underscore if the record type allows it';
public const string FAILURE_REASON_INVALID_WILDCARD = 'Wildcard "*" must be the entire leftmost label';

public const string FAILURE_REASON_GENERAL = 'Name must be between 1 and 255 characters long, and contain only alpha-numeric characters, hyphens and (for non-address record types) underscores, and cannot start or end with a hyphen';

public string $reason = '';

private int $recordType;
private ?int $recordType;

public function __construct(int $recordType)
/**
* @param int|null $recordType Record type code, or null to apply the general domain name rules.
*/
public function __construct(?int $recordType = null)
{
$this->recordType = $recordType;
}
Expand All @@ -47,7 +59,6 @@ public function isValid(mixed $name): bool
// DNS names are made up of labels separated by dots.
// Each label: 1-63 chars, letters, digits, hyphens, can't start/end w/ hyphen.
// Full name: <=255 chars, labels separated by single dots, no empty labels unless root.
// If the record type allows underscores in the name, they are allowed in the name.

if (\strlen($name) < 1 || \strlen($name) > Domain::MAX_DOMAIN_NAME_LEN) {
$this->reason = self::FAILURE_REASON_INVALID_NAME_LENGTH;
Expand All @@ -61,9 +72,22 @@ public function isValid(mixed $name): bool

// If the name ends with '.', strip it (absolute FQDN); allow trailing '.'.
$trimmed = (\substr($name, -1) === '.') ? \substr($name, 0, -1) : $name;

// RFC 4592: a wildcard is a single '*' as the entire leftmost label.
if ($trimmed === '*') {
return true;
}
if (\str_starts_with($trimmed, '*.')) {
$trimmed = \substr($trimmed, 2);
}
if (\str_contains($trimmed, '*')) {
$this->reason = self::FAILURE_REASON_INVALID_WILDCARD;
return false;
}

$labels = \explode('.', $trimmed);

$isUnderscoreAllowed = \in_array($this->recordType, self::RECORD_TYPES_WITH_UNDERSCORE_IN_NAME);
$isUnderscoreAllowed = !\in_array($this->recordType, self::RECORD_TYPES_WITH_HOSTNAME_OWNER, true);

foreach ($labels as $label) {
if ($label === '') {
Expand All @@ -76,10 +100,7 @@ public function isValid(mixed $name): bool
return false;
}

// RFC: Only a-z 0-9 -, can't start or end with '-'
// May contain '_' if the record type allows it.
$len = \strlen($label);
// Check label contains only allowed chars
for ($i = 0; $i < $len; ++$i) {
if (!$this->isValidCharacter($label[$i], $i === 0 || $i === $len - 1, $isUnderscoreAllowed)) {
$this->reason = $isUnderscoreAllowed ? self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE : self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE;
Expand Down
54 changes: 51 additions & 3 deletions tests/e2e/DNS/Validator/NameTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,42 @@ public function testValid(): void
'123.com',
'example.com.',
str_repeat('a', 63) . '.com',
// RFC 4592: wildcard as the entire leftmost label
'*',
'*.',
'*.example.com',
'*.example.com.',
// RFC 8552: underscored owner names are legal for non-address records
'_dmarc',
'_acme-challenge',
'selector1._domainkey',
'mail._domainkey.example.com',
'exa_mple.com',
];

foreach ($validValues as $value) {
$this->assertTrue($validator->isValid($value), "Expected valid: {$value}");
}

// Type that allows underscores in name
$validator = new Name(Record::TYPE_SRV);
$this->assertTrue($validator->isValid('example._tcp.com'), "Expected valid: example._tcp.com");

// No record type applies the general domain name rules
$validator = new Name();
$this->assertTrue($validator->isValid('selector1._domainkey'), "Expected valid: selector1._domainkey");
$this->assertTrue($validator->isValid('*.example.com'), "Expected valid: *.example.com");

// Address records still allow wildcards, just not underscores
$validator = new Name(Record::TYPE_A);
$this->assertTrue($validator->isValid('*'), "Expected valid: *");
$this->assertTrue($validator->isValid('*.example.com'), "Expected valid: *.example.com");
$this->assertFalse($validator->isValid('_dmarc'), "Expected invalid: _dmarc");
}

public function testInvalid(): void
{
$validator = new Name(Record::TYPE_CNAME);
// Address records: owner name must be a valid host name (no underscores)
$validator = new Name(Record::TYPE_A);

$invalidValues = [
['value' => 123, 'description' => Name::FAILURE_REASON_GENERAL],
Expand All @@ -58,7 +80,7 @@ public function testInvalid(): void
$this->assertSame($value['description'], $validator->getDescription());
}

// Type that allows underscores in name
// Non-address records: underscores allowed, everything else still invalid
$validator = new Name(Record::TYPE_TXT);

$invalidValues = [
Expand All @@ -73,11 +95,37 @@ public function testInvalid(): void
['value' => '.example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
['value' => 'example.com..', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
['value' => 'exa mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
['value' => 'google console', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
];

foreach ($invalidValues as $value) {
$this->assertFalse($validator->isValid($value['value']), "Expected invalid: {$value['value']}");
$this->assertSame($value['description'], $validator->getDescription());
}
}

public function testInvalidWildcard(): void
{
$validator = new Name(Record::TYPE_CNAME);

$invalidValues = [
'foo.*.com',
'foo.*',
'*foo.com',
'f*o.com',
'*a',
'a*',
'**',
'*.*.example.com',
];

foreach ($invalidValues as $value) {
$this->assertFalse($validator->isValid($value), "Expected invalid: {$value}");
$this->assertSame(Name::FAILURE_REASON_INVALID_WILDCARD, $validator->getDescription());
}

// '*..com' fails on the empty label left after the wildcard is stripped
$this->assertFalse($validator->isValid('*..com'), "Expected invalid: *..com");
$this->assertSame(Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE, $validator->getDescription());
}
}
Loading