diff --git a/src/DNS/Validator/Name.php b/src/DNS/Validator/Name.php index fe883bf..ed206cb 100644 --- a/src/DNS/Validator/Name.php +++ b/src/DNS/Validator/Name.php @@ -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; @@ -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; } @@ -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; @@ -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 === '') { @@ -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; diff --git a/tests/e2e/DNS/Validator/NameTest.php b/tests/e2e/DNS/Validator/NameTest.php index b38bde8..7694c4b 100644 --- a/tests/e2e/DNS/Validator/NameTest.php +++ b/tests/e2e/DNS/Validator/NameTest.php @@ -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], @@ -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 = [ @@ -73,6 +95,7 @@ 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) { @@ -80,4 +103,29 @@ public function testInvalid(): void $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()); + } }