Skip to content

Commit c09469c

Browse files
authored
Follow RFC owner name rules in Name validator (wildcards + underscored labels) (#50)
* Follow RFC owner name rules in Name validator Allow wildcard owner names ('*' as the entire leftmost label, RFC 4592) and underscored labels (RFC 8552) for all record types except A/AAAA, whose owner names must be valid host names (RFC 952, RFC 1123). The record type constructor argument is now optional; null applies the general domain name rules. * (fix): update general failure reason to match inverted underscore rule
1 parent 5ee1c16 commit c09469c

2 files changed

Lines changed: 81 additions & 12 deletions

File tree

src/DNS/Validator/Name.php

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88

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

1320
public const int LABEL_MAX_LENGTH = 63;
1421

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

2128
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';
2229

23-
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';
30+
public const string FAILURE_REASON_INVALID_WILDCARD = 'Wildcard "*" must be the entire leftmost label';
31+
32+
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';
2433

2534
public string $reason = '';
2635

27-
private int $recordType;
36+
private ?int $recordType;
2837

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

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

6273
// If the name ends with '.', strip it (absolute FQDN); allow trailing '.'.
6374
$trimmed = (\substr($name, -1) === '.') ? \substr($name, 0, -1) : $name;
75+
76+
// RFC 4592: a wildcard is a single '*' as the entire leftmost label.
77+
if ($trimmed === '*') {
78+
return true;
79+
}
80+
if (\str_starts_with($trimmed, '*.')) {
81+
$trimmed = \substr($trimmed, 2);
82+
}
83+
if (\str_contains($trimmed, '*')) {
84+
$this->reason = self::FAILURE_REASON_INVALID_WILDCARD;
85+
return false;
86+
}
87+
6488
$labels = \explode('.', $trimmed);
6589

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

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

79-
// RFC: Only a-z 0-9 -, can't start or end with '-'
80-
// May contain '_' if the record type allows it.
81103
$len = \strlen($label);
82-
// Check label contains only allowed chars
83104
for ($i = 0; $i < $len; ++$i) {
84105
if (!$this->isValidCharacter($label[$i], $i === 0 || $i === $len - 1, $isUnderscoreAllowed)) {
85106
$this->reason = $isUnderscoreAllowed ? self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE : self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE;

tests/e2e/DNS/Validator/NameTest.php

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,42 @@ public function testValid(): void
2323
'123.com',
2424
'example.com.',
2525
str_repeat('a', 63) . '.com',
26+
// RFC 4592: wildcard as the entire leftmost label
27+
'*',
28+
'*.',
29+
'*.example.com',
30+
'*.example.com.',
31+
// RFC 8552: underscored owner names are legal for non-address records
32+
'_dmarc',
33+
'_acme-challenge',
34+
'selector1._domainkey',
35+
'mail._domainkey.example.com',
36+
'exa_mple.com',
2637
];
2738

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

32-
// Type that allows underscores in name
3343
$validator = new Name(Record::TYPE_SRV);
3444
$this->assertTrue($validator->isValid('example._tcp.com'), "Expected valid: example._tcp.com");
45+
46+
// No record type applies the general domain name rules
47+
$validator = new Name();
48+
$this->assertTrue($validator->isValid('selector1._domainkey'), "Expected valid: selector1._domainkey");
49+
$this->assertTrue($validator->isValid('*.example.com'), "Expected valid: *.example.com");
50+
51+
// Address records still allow wildcards, just not underscores
52+
$validator = new Name(Record::TYPE_A);
53+
$this->assertTrue($validator->isValid('*'), "Expected valid: *");
54+
$this->assertTrue($validator->isValid('*.example.com'), "Expected valid: *.example.com");
55+
$this->assertFalse($validator->isValid('_dmarc'), "Expected invalid: _dmarc");
3556
}
3657

3758
public function testInvalid(): void
3859
{
39-
$validator = new Name(Record::TYPE_CNAME);
60+
// Address records: owner name must be a valid host name (no underscores)
61+
$validator = new Name(Record::TYPE_A);
4062

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

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

6486
$invalidValues = [
@@ -73,11 +95,37 @@ public function testInvalid(): void
7395
['value' => '.example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
7496
['value' => 'example.com..', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
7597
['value' => 'exa mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
98+
['value' => 'google console', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
7699
];
77100

78101
foreach ($invalidValues as $value) {
79102
$this->assertFalse($validator->isValid($value['value']), "Expected invalid: {$value['value']}");
80103
$this->assertSame($value['description'], $validator->getDescription());
81104
}
82105
}
106+
107+
public function testInvalidWildcard(): void
108+
{
109+
$validator = new Name(Record::TYPE_CNAME);
110+
111+
$invalidValues = [
112+
'foo.*.com',
113+
'foo.*',
114+
'*foo.com',
115+
'f*o.com',
116+
'*a',
117+
'a*',
118+
'**',
119+
'*.*.example.com',
120+
];
121+
122+
foreach ($invalidValues as $value) {
123+
$this->assertFalse($validator->isValid($value), "Expected invalid: {$value}");
124+
$this->assertSame(Name::FAILURE_REASON_INVALID_WILDCARD, $validator->getDescription());
125+
}
126+
127+
// '*..com' fails on the empty label left after the wildcard is stripped
128+
$this->assertFalse($validator->isValid('*..com'), "Expected invalid: *..com");
129+
$this->assertSame(Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE, $validator->getDescription());
130+
}
83131
}

0 commit comments

Comments
 (0)