From f7e7e16235ab3c66fff1df133957e9555a01fac0 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 4 Dec 2025 13:52:09 +0530 Subject: [PATCH 1/8] Add Name & CAA validators --- src/DNS/Validator/CAA.php | 117 ++++++++++++++++++++++++++ src/DNS/Validator/Name.php | 81 ++++++++++++++++++ tests/unit/DNS/Validator/CAATest.php | 43 ++++++++++ tests/unit/DNS/Validator/NameTest.php | 55 ++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 src/DNS/Validator/CAA.php create mode 100644 src/DNS/Validator/Name.php create mode 100644 tests/unit/DNS/Validator/CAATest.php create mode 100644 tests/unit/DNS/Validator/NameTest.php diff --git a/src/DNS/Validator/CAA.php b/src/DNS/Validator/CAA.php new file mode 100644 index 0000000..3269e74 --- /dev/null +++ b/src/DNS/Validator/CAA.php @@ -0,0 +1,117 @@ +reason = self::FAILURE_REASON_INVALID_FORMAT; + return false; + } + + $parts = explode(" ", $data); + + if (count($parts) !== 3) { + $this->reason = self::FAILURE_REASON_INVALID_FORMAT; + return false; + } + + $flags = $parts[0]; + $tag = $parts[1]; + $value = $parts[2]; + + // Check flags is a number + if (!is_numeric($flags)) { + $this->reason = self::FAILURE_REASON_INVALID_FLAGS; + return false; + } + + $flags = (int) $flags; + + // Check flags is within the allowed range + if ($flags < self::CAA_FLAG_MIN || $flags > self::CAA_FLAG_MAX) { + $this->reason = self::FAILURE_REASON_INVALID_FLAGS; + return false; + } + + // Check tag is not empty + if (strlen($tag) === 0) { + $this->reason = self::FAILURE_REASON_INVALID_TAG; + return false; + } + + // Check value is not empty and starts with " and ends with " + if (strlen($value) === 0 || $value[0] !== '"' || $value[strlen($value) - 1] !== '"') { + $this->reason = self::FAILURE_REASON_INVALID_VALUE; + return false; + } + + $value = substr($value, 1, strlen($value) - 2); + + // Check value is not empty after removing the quotes + if (strlen($value) === 0) { + $this->reason = self::FAILURE_REASON_INVALID_VALUE; + return false; + } + + // All checks passed + return true; + } + + public function getDescription(): string + { + $messages = ['CAA verification failed']; + + $messages[] = !empty($this->reason) ? $this->reason : self::FAILURE_REASON_INVALID_FORMAT; + + return implode('. ', $messages) . '.'; + } + + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/src/DNS/Validator/Name.php b/src/DNS/Validator/Name.php new file mode 100644 index 0000000..1566bb3 --- /dev/null +++ b/src/DNS/Validator/Name.php @@ -0,0 +1,81 @@ + Domain::MAX_DOMAIN_NAME_LEN) { + return false; + } + + // If the name ends with '.', strip it (absolute FQDN); allow trailing '.'. + $trimmed = (substr($name, -1) === '.') ? substr($name, 0, -1) : $name; + + $labels = explode('.', $trimmed); + + // Disallow empty label except root "." (which means $trimmed = '') + foreach ($labels as $label) { + if ($label === '' || strlen($label) > 63 || strlen($label) < 1) { + return false; + } + // RFC: Only a-z 0-9 -, can't start or end with '-' + + // Check first and last character are alphanumeric + $len = strlen($label); + if ( + $len < 1 || + !ctype_alnum($label[0]) || + !ctype_alnum($label[$len - 1]) + ) { + return false; + } + + // Check label contains only allowed chars + for ($i = 0; $i < $len - 1; ++$i) { + $c = $label[$i]; + if (!ctype_alnum($c) && $c !== '-') { + return false; + } + } + } + + return true; + } + + /** + * @inheritDoc + */ + public function getDescription(): string + { + return 'Invalid name for DNS record'; + } + + /** + * @inheritDoc + */ + public function getType(): string + { + return self::TYPE_STRING; + } + + /** + * @inheritDoc + */ + public function isArray(): bool + { + return false; + } +} \ No newline at end of file diff --git a/tests/unit/DNS/Validator/CAATest.php b/tests/unit/DNS/Validator/CAATest.php new file mode 100644 index 0000000..9b9d4e4 --- /dev/null +++ b/tests/unit/DNS/Validator/CAATest.php @@ -0,0 +1,43 @@ +assertTrue($validator->isValid($value), "Expected valid: {$value}"); + } + } + + public function testInvalid(): void + { + $validator = new CAA(); + + $invalidValues = [ + ['value' => 'issue "letsencrypt.org"', 'description' => 'Invalid format'], + ['value' => '256 issue "letsencrypt.org"', 'description' => 'Invalid flags'], + ['value' => '0 issue letsencrypt.org', 'description' => 'Invalid value'], + ['value' => '0 issue ""', 'description' => 'Invalid value'], + ]; + + foreach ($invalidValues as $invalidValue) { + $this->assertFalse($validator->isValid($invalidValue['value']), "Expected invalid: {$invalidValue['value']}"); + $this->assertSame("CAA verification failed. {$invalidValue['description']}.", $validator->getDescription()); + } + } +} \ No newline at end of file diff --git a/tests/unit/DNS/Validator/NameTest.php b/tests/unit/DNS/Validator/NameTest.php new file mode 100644 index 0000000..ce79031 --- /dev/null +++ b/tests/unit/DNS/Validator/NameTest.php @@ -0,0 +1,55 @@ +assertTrue($validator->isValid($value), "Expected valid: {$value}"); + } + } + + public function testInvalid(): void + { + $validator = new Name(); + + $invalidValues = [ + '', + '-example.com', + 'example-.com', + 'exa_mple.com', + 'example..com', + str_repeat('a', 64) . '.com', + 123, + '.example.com', + 'example.com..', + 'exa mple.com', + ]; + + foreach ($invalidValues as $value) { + $this->assertFalse($validator->isValid($value), 'Expected invalid value'); + $this->assertSame('Invalid name for DNS record', $validator->getDescription()); + } + + } +} + From 43ce4178b5dd93a8c764e12b9bdd69b1e5bfcb6b Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 4 Dec 2025 14:27:15 +0530 Subject: [PATCH 2/8] limit --- src/DNS/Validator/CAA.php | 2 +- tests/unit/DNS/Validator/CAATest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/DNS/Validator/CAA.php b/src/DNS/Validator/CAA.php index 3269e74..410521d 100644 --- a/src/DNS/Validator/CAA.php +++ b/src/DNS/Validator/CAA.php @@ -33,7 +33,7 @@ public function isValid(mixed $data): bool return false; } - $parts = explode(" ", $data); + $parts = explode(" ", $data, 3); if (count($parts) !== 3) { $this->reason = self::FAILURE_REASON_INVALID_FORMAT; diff --git a/tests/unit/DNS/Validator/CAATest.php b/tests/unit/DNS/Validator/CAATest.php index 9b9d4e4..ea72c1c 100644 --- a/tests/unit/DNS/Validator/CAATest.php +++ b/tests/unit/DNS/Validator/CAATest.php @@ -17,6 +17,7 @@ public function testValid(): void '0 issuewild "certainly.com"', '0 iodef "mailto:security@example.com"', '0 issue ";"', + '0 issue "certainly.com; validationmethods=dns-01"', ]; foreach ($validValues as $value) { From 0e0b996e853ad8f0e364b8a17c3fe80241322b9e Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 4 Dec 2025 14:34:20 +0530 Subject: [PATCH 3/8] lint --- src/DNS/Validator/CAA.php | 10 +++++----- src/DNS/Validator/Name.php | 4 ++-- tests/unit/DNS/Validator/CAATest.php | 10 +++++----- tests/unit/DNS/Validator/NameTest.php | 1 - 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/DNS/Validator/CAA.php b/src/DNS/Validator/CAA.php index 410521d..ec961e3 100644 --- a/src/DNS/Validator/CAA.php +++ b/src/DNS/Validator/CAA.php @@ -11,9 +11,9 @@ class CAA extends Validator protected const int CAA_FLAG_MAX = 255; protected const string FAILURE_REASON_INVALID_FLAGS = 'Invalid flags'; - + protected const string FAILURE_REASON_INVALID_TAG = 'Invalid tag'; - + protected const string FAILURE_REASON_INVALID_VALUE = 'Invalid value'; protected const string FAILURE_REASON_INVALID_FORMAT = 'Invalid format'; @@ -23,7 +23,7 @@ class CAA extends Validator /** * Check if the provided value matches the CAA record format * - * @param mixed $value + * @param mixed $data * @return bool */ public function isValid(mixed $data): bool @@ -57,13 +57,13 @@ public function isValid(mixed $data): bool $this->reason = self::FAILURE_REASON_INVALID_FLAGS; return false; } - + // Check tag is not empty if (strlen($tag) === 0) { $this->reason = self::FAILURE_REASON_INVALID_TAG; return false; } - + // Check value is not empty and starts with " and ends with " if (strlen($value) === 0 || $value[0] !== '"' || $value[strlen($value) - 1] !== '"') { $this->reason = self::FAILURE_REASON_INVALID_VALUE; diff --git a/src/DNS/Validator/Name.php b/src/DNS/Validator/Name.php index 1566bb3..cc62730 100644 --- a/src/DNS/Validator/Name.php +++ b/src/DNS/Validator/Name.php @@ -16,7 +16,7 @@ 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 (strlen($name) < 1 || strlen($name) > Domain::MAX_DOMAIN_NAME_LEN) { return false; } @@ -78,4 +78,4 @@ public function isArray(): bool { return false; } -} \ No newline at end of file +} diff --git a/tests/unit/DNS/Validator/CAATest.php b/tests/unit/DNS/Validator/CAATest.php index ea72c1c..633520b 100644 --- a/tests/unit/DNS/Validator/CAATest.php +++ b/tests/unit/DNS/Validator/CAATest.php @@ -10,7 +10,7 @@ final class CAATest extends TestCase public function testValid(): void { $validator = new CAA(); - + $validValues = [ '0 issue "letsencrypt.org"', '128 issuewild "certainly.com;account=123456;validationmethods=dns-01"', @@ -19,7 +19,7 @@ public function testValid(): void '0 issue ";"', '0 issue "certainly.com; validationmethods=dns-01"', ]; - + foreach ($validValues as $value) { $this->assertTrue($validator->isValid($value), "Expected valid: {$value}"); } @@ -28,17 +28,17 @@ public function testValid(): void public function testInvalid(): void { $validator = new CAA(); - + $invalidValues = [ ['value' => 'issue "letsencrypt.org"', 'description' => 'Invalid format'], ['value' => '256 issue "letsencrypt.org"', 'description' => 'Invalid flags'], ['value' => '0 issue letsencrypt.org', 'description' => 'Invalid value'], ['value' => '0 issue ""', 'description' => 'Invalid value'], ]; - + foreach ($invalidValues as $invalidValue) { $this->assertFalse($validator->isValid($invalidValue['value']), "Expected invalid: {$invalidValue['value']}"); $this->assertSame("CAA verification failed. {$invalidValue['description']}.", $validator->getDescription()); } } -} \ No newline at end of file +} diff --git a/tests/unit/DNS/Validator/NameTest.php b/tests/unit/DNS/Validator/NameTest.php index ce79031..751b17f 100644 --- a/tests/unit/DNS/Validator/NameTest.php +++ b/tests/unit/DNS/Validator/NameTest.php @@ -52,4 +52,3 @@ public function testInvalid(): void } } - From 867ce1afb918cc31c685437514795d7307afa9e3 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 4 Dec 2025 14:40:45 +0530 Subject: [PATCH 4/8] tiny --- src/DNS/Validator/Name.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DNS/Validator/Name.php b/src/DNS/Validator/Name.php index cc62730..f691da8 100644 --- a/src/DNS/Validator/Name.php +++ b/src/DNS/Validator/Name.php @@ -44,7 +44,7 @@ public function isValid(mixed $name): bool } // Check label contains only allowed chars - for ($i = 0; $i < $len - 1; ++$i) { + for ($i = 1; $i < $len - 1; ++$i) { $c = $label[$i]; if (!ctype_alnum($c) && $c !== '-') { return false; From ec46b84927d8517d51f46c4695a60257fd6c3e38 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 4 Dec 2025 15:14:05 +0530 Subject: [PATCH 5/8] better error msgs --- src/DNS/Validator/CAA.php | 20 +++++----- src/DNS/Validator/Name.php | 54 +++++++++++++++++++++------ tests/unit/DNS/Validator/CAATest.php | 11 +++--- tests/unit/DNS/Validator/NameTest.php | 25 +++++++------ 4 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/DNS/Validator/CAA.php b/src/DNS/Validator/CAA.php index ec961e3..e92baa2 100644 --- a/src/DNS/Validator/CAA.php +++ b/src/DNS/Validator/CAA.php @@ -6,17 +6,17 @@ class CAA extends Validator { - protected const int CAA_FLAG_MIN = 0; + public const int CAA_FLAG_MIN = 0; - protected const int CAA_FLAG_MAX = 255; + public const int CAA_FLAG_MAX = 255; - protected const string FAILURE_REASON_INVALID_FLAGS = 'Invalid flags'; + public const string FAILURE_REASON_INVALID_FLAGS = 'Flags must be a number between 0 and 255'; - protected const string FAILURE_REASON_INVALID_TAG = 'Invalid tag'; + public const string FAILURE_REASON_INVALID_TAG = 'Tag must be a non-empty string'; - protected const string FAILURE_REASON_INVALID_VALUE = 'Invalid value'; + public const string FAILURE_REASON_INVALID_VALUE = 'Value must be a non-empty string and must be enclosed in quotes'; - protected const string FAILURE_REASON_INVALID_FORMAT = 'Invalid format'; + public const string FAILURE_REASON_INVALID_FORMAT = 'CAA record must be in the format flags tag "value"'; public string $reason = ''; @@ -84,11 +84,11 @@ public function isValid(mixed $data): bool public function getDescription(): string { - $messages = ['CAA verification failed']; - - $messages[] = !empty($this->reason) ? $this->reason : self::FAILURE_REASON_INVALID_FORMAT; + if (!empty($this->reason)) { + return $this->reason; + } - return implode('. ', $messages) . '.'; + return self::FAILURE_REASON_INVALID_FORMAT; } /** diff --git a/src/DNS/Validator/Name.php b/src/DNS/Validator/Name.php index f691da8..9e63257 100644 --- a/src/DNS/Validator/Name.php +++ b/src/DNS/Validator/Name.php @@ -7,9 +7,28 @@ class Name extends Validator { + public const int LABEL_MAX_LENGTH = 63; + + public const string FAILURE_REASON_INVALID_LABEL_LENGTH = 'Label must be between 1 and 63 characters long'; + + public const string FAILURE_REASON_INVALID_NAME_LENGTH = 'Name must be between 1 and 255 characters long'; + + public const string FAILURE_REASON_INVALID_LABEL_CHARACTERS = 'Label must contain only alpha-numeric characters and hyphens, 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'; + + public string $reason = ''; + + /** + * Check if the provided value matches the Name record format + * + * @param mixed $name + * @return bool + */ public function isValid(mixed $name): bool { - if (!is_string($name)) { + if (!\is_string($name)) { + $this->reason = self::FAILURE_REASON_GENERAL; return false; } @@ -17,36 +36,45 @@ public function isValid(mixed $name): bool // 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 (strlen($name) < 1 || strlen($name) > Domain::MAX_DOMAIN_NAME_LEN) { + if (\strlen($name) < 1 || \strlen($name) > Domain::MAX_DOMAIN_NAME_LEN) { + $this->reason = self::FAILURE_REASON_INVALID_NAME_LENGTH; return false; } // If the name ends with '.', strip it (absolute FQDN); allow trailing '.'. - $trimmed = (substr($name, -1) === '.') ? substr($name, 0, -1) : $name; + $trimmed = (\substr($name, -1) === '.') ? \substr($name, 0, -1) : $name; - $labels = explode('.', $trimmed); + $labels = \explode('.', $trimmed); // Disallow empty label except root "." (which means $trimmed = '') foreach ($labels as $label) { - if ($label === '' || strlen($label) > 63 || strlen($label) < 1) { + if ($label === '') { + $this->reason = self::FAILURE_REASON_INVALID_LABEL_CHARACTERS; + return false; + } + + if (\strlen($label) > self::LABEL_MAX_LENGTH) { + $this->reason = self::FAILURE_REASON_INVALID_LABEL_LENGTH; return false; } + // RFC: Only a-z 0-9 -, can't start or end with '-' // Check first and last character are alphanumeric - $len = strlen($label); + $len = \strlen($label); if ( - $len < 1 || - !ctype_alnum($label[0]) || - !ctype_alnum($label[$len - 1]) + !\ctype_alnum($label[0]) || + !\ctype_alnum($label[$len - 1]) ) { + $this->reason = self::FAILURE_REASON_INVALID_LABEL_CHARACTERS; return false; } // Check label contains only allowed chars for ($i = 1; $i < $len - 1; ++$i) { $c = $label[$i]; - if (!ctype_alnum($c) && $c !== '-') { + if (!\ctype_alnum($c) && $c !== '-') { + $this->reason = self::FAILURE_REASON_INVALID_LABEL_CHARACTERS; return false; } } @@ -60,7 +88,11 @@ public function isValid(mixed $name): bool */ public function getDescription(): string { - return 'Invalid name for DNS record'; + if (!empty($this->reason)) { + return $this->reason; + } + + return self::FAILURE_REASON_GENERAL; } /** diff --git a/tests/unit/DNS/Validator/CAATest.php b/tests/unit/DNS/Validator/CAATest.php index 633520b..12952fd 100644 --- a/tests/unit/DNS/Validator/CAATest.php +++ b/tests/unit/DNS/Validator/CAATest.php @@ -30,15 +30,16 @@ public function testInvalid(): void $validator = new CAA(); $invalidValues = [ - ['value' => 'issue "letsencrypt.org"', 'description' => 'Invalid format'], - ['value' => '256 issue "letsencrypt.org"', 'description' => 'Invalid flags'], - ['value' => '0 issue letsencrypt.org', 'description' => 'Invalid value'], - ['value' => '0 issue ""', 'description' => 'Invalid value'], + ['value' => 'issue "letsencrypt.org"', 'description' => CAA::FAILURE_REASON_INVALID_FORMAT], + ['value' => '0 ""', 'description' => CAA::FAILURE_REASON_INVALID_FORMAT], + ['value' => '256 issue "letsencrypt.org"', 'description' => CAA::FAILURE_REASON_INVALID_FLAGS], + ['value' => '0 issue letsencrypt.org', 'description' => CAA::FAILURE_REASON_INVALID_VALUE], + ['value' => '0 issue ""', 'description' => CAA::FAILURE_REASON_INVALID_VALUE], ]; foreach ($invalidValues as $invalidValue) { $this->assertFalse($validator->isValid($invalidValue['value']), "Expected invalid: {$invalidValue['value']}"); - $this->assertSame("CAA verification failed. {$invalidValue['description']}.", $validator->getDescription()); + $this->assertSame($invalidValue['description'], $validator->getDescription()); } } } diff --git a/tests/unit/DNS/Validator/NameTest.php b/tests/unit/DNS/Validator/NameTest.php index 751b17f..eb656f3 100644 --- a/tests/unit/DNS/Validator/NameTest.php +++ b/tests/unit/DNS/Validator/NameTest.php @@ -33,21 +33,22 @@ public function testInvalid(): void $validator = new Name(); $invalidValues = [ - '', - '-example.com', - 'example-.com', - 'exa_mple.com', - 'example..com', - str_repeat('a', 64) . '.com', - 123, - '.example.com', - 'example.com..', - 'exa mple.com', + ['value' => 123, 'description' => Name::FAILURE_REASON_GENERAL], + ['value' => '', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], + ['value' => str_repeat('a', 256) . '.com', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], + ['value' => str_repeat('a', 64) . '.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_LENGTH], + ['value' => '-example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], + ['value' => 'example-.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], + ['value' => 'exa_mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], + ['value' => 'example..com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], + ['value' => '.example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], + ['value' => 'example.com..', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], + ['value' => 'exa mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], ]; foreach ($invalidValues as $value) { - $this->assertFalse($validator->isValid($value), 'Expected invalid value'); - $this->assertSame('Invalid name for DNS record', $validator->getDescription()); + $this->assertFalse($validator->isValid($value['value']), "Expected invalid: {$value['value']}"); + $this->assertSame($value['description'], $validator->getDescription()); } } From b78d6834d9af89c3de37d90fc51ab10474b069ba Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 4 Dec 2025 15:34:46 +0530 Subject: [PATCH 6/8] allow underscore in some records --- src/DNS/Validator/Name.php | 41 ++++++++++++++++----------- tests/unit/DNS/Validator/NameTest.php | 10 +++++-- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/DNS/Validator/Name.php b/src/DNS/Validator/Name.php index 9e63257..cbe68f8 100644 --- a/src/DNS/Validator/Name.php +++ b/src/DNS/Validator/Name.php @@ -3,22 +3,32 @@ namespace Utopia\DNS\Validator; use Utopia\DNS\Message\Domain; +use Utopia\DNS\Message\Record; use Utopia\Validator; class Name extends Validator { + private const array RECORD_TYPES_WITH_UNDERSCORE_IN_NAME = [Record::TYPE_SRV, Record::TYPE_TXT]; + public const int LABEL_MAX_LENGTH = 63; public const string FAILURE_REASON_INVALID_LABEL_LENGTH = 'Label must be between 1 and 63 characters long'; public const string FAILURE_REASON_INVALID_NAME_LENGTH = 'Name must be between 1 and 255 characters long'; - public const string FAILURE_REASON_INVALID_LABEL_CHARACTERS = 'Label must contain only alpha-numeric characters and hyphens, and cannot start or end with a hyphen'; + public const string FAILURE_REASON_INVALID_LABEL_CHARACTERS = 'Label must 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_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'; + 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 string $reason = ''; + private int $recordType; + + public function __construct(int $recordType) + { + $this->recordType = $recordType; + } + /** * Check if the provided value matches the Name record format * @@ -35,6 +45,7 @@ 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; @@ -43,7 +54,6 @@ 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; - $labels = \explode('.', $trimmed); // Disallow empty label except root "." (which means $trimmed = '') @@ -59,21 +69,11 @@ public function isValid(mixed $name): bool } // RFC: Only a-z 0-9 -, can't start or end with '-' - - // Check first and last character are alphanumeric + // May contain '_' if the record type allows it. $len = \strlen($label); - if ( - !\ctype_alnum($label[0]) || - !\ctype_alnum($label[$len - 1]) - ) { - $this->reason = self::FAILURE_REASON_INVALID_LABEL_CHARACTERS; - return false; - } - // Check label contains only allowed chars - for ($i = 1; $i < $len - 1; ++$i) { - $c = $label[$i]; - if (!\ctype_alnum($c) && $c !== '-') { + for ($i = 0; $i < $len; ++$i) { + if (!$this->isValidCharacter($label[$i], $i === 0 || $i === $len - 1)) { $this->reason = self::FAILURE_REASON_INVALID_LABEL_CHARACTERS; return false; } @@ -83,6 +83,15 @@ public function isValid(mixed $name): bool return true; } + private function isValidCharacter(string $char, bool $isFirstOrLast): bool + { + $isUnderscoreAllowed = \in_array($this->recordType, self::RECORD_TYPES_WITH_UNDERSCORE_IN_NAME); + if ($isFirstOrLast) { + return \ctype_alnum($char) || ($isUnderscoreAllowed && $char === '_'); + } + return \ctype_alnum($char) || $char === '-' || ($isUnderscoreAllowed && $char === '_'); + } + /** * @inheritDoc */ diff --git a/tests/unit/DNS/Validator/NameTest.php b/tests/unit/DNS/Validator/NameTest.php index eb656f3..08807c8 100644 --- a/tests/unit/DNS/Validator/NameTest.php +++ b/tests/unit/DNS/Validator/NameTest.php @@ -3,13 +3,14 @@ namespace Tests\Unit\Utopia\DNS\Validator; use PHPUnit\Framework\TestCase; +use Utopia\DNS\Message\Record; use Utopia\DNS\Validator\Name; final class NameTest extends TestCase { public function testValid(): void { - $validator = new Name(); + $validator = new Name(Record::TYPE_CNAME); $validValues = [ 'example', @@ -26,11 +27,15 @@ public function testValid(): void foreach ($validValues as $value) { $this->assertTrue($validator->isValid($value), "Expected valid: {$value}"); } + + // Allowed underscores in name + $validator = new Name(Record::TYPE_SRV); + $this->assertTrue($validator->isValid('example._tcp.com'), "Expected valid: example._tcp.com"); } public function testInvalid(): void { - $validator = new Name(); + $validator = new Name(Record::TYPE_CNAME); $invalidValues = [ ['value' => 123, 'description' => Name::FAILURE_REASON_GENERAL], @@ -50,6 +55,5 @@ public function testInvalid(): void $this->assertFalse($validator->isValid($value['value']), "Expected invalid: {$value['value']}"); $this->assertSame($value['description'], $validator->getDescription()); } - } } From d942266694c53d63abb31db9ad9ae46cae01f030 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 4 Dec 2025 17:45:01 +0530 Subject: [PATCH 7/8] tweak error msgs --- src/DNS/Validator/CAA.php | 2 +- src/DNS/Validator/Name.php | 16 +++++++----- tests/unit/DNS/Validator/NameTest.php | 37 +++++++++++++++++++++------ 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/DNS/Validator/CAA.php b/src/DNS/Validator/CAA.php index e92baa2..3fec5cd 100644 --- a/src/DNS/Validator/CAA.php +++ b/src/DNS/Validator/CAA.php @@ -16,7 +16,7 @@ class CAA extends Validator public const string FAILURE_REASON_INVALID_VALUE = 'Value must be a non-empty string and must be enclosed in quotes'; - public const string FAILURE_REASON_INVALID_FORMAT = 'CAA record must be in the format flags tag "value"'; + public const string FAILURE_REASON_INVALID_FORMAT = 'CAA record must be in the format ""'; public string $reason = ''; diff --git a/src/DNS/Validator/Name.php b/src/DNS/Validator/Name.php index cbe68f8..4d72d80 100644 --- a/src/DNS/Validator/Name.php +++ b/src/DNS/Validator/Name.php @@ -16,7 +16,9 @@ class Name extends Validator public const string FAILURE_REASON_INVALID_NAME_LENGTH = 'Name must be between 1 and 255 characters long'; - public const string FAILURE_REASON_INVALID_LABEL_CHARACTERS = 'Label must 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_LABEL_CHARACTERS_WITHOUT_UNDERSCORE = 'Label must contain only alpha-numeric characters and hyphens, and cannot start or end with a hyphen'; + + 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'; @@ -56,10 +58,11 @@ public function isValid(mixed $name): bool $trimmed = (\substr($name, -1) === '.') ? \substr($name, 0, -1) : $name; $labels = \explode('.', $trimmed); - // Disallow empty label except root "." (which means $trimmed = '') + $isUnderscoreAllowed = \in_array($this->recordType, self::RECORD_TYPES_WITH_UNDERSCORE_IN_NAME); + foreach ($labels as $label) { if ($label === '') { - $this->reason = self::FAILURE_REASON_INVALID_LABEL_CHARACTERS; + $this->reason = $isUnderscoreAllowed ? self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE : self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE; return false; } @@ -73,8 +76,8 @@ public function isValid(mixed $name): bool $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)) { - $this->reason = self::FAILURE_REASON_INVALID_LABEL_CHARACTERS; + 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; return false; } } @@ -83,9 +86,8 @@ public function isValid(mixed $name): bool return true; } - private function isValidCharacter(string $char, bool $isFirstOrLast): bool + private function isValidCharacter(string $char, bool $isFirstOrLast, bool $isUnderscoreAllowed): bool { - $isUnderscoreAllowed = \in_array($this->recordType, self::RECORD_TYPES_WITH_UNDERSCORE_IN_NAME); if ($isFirstOrLast) { return \ctype_alnum($char) || ($isUnderscoreAllowed && $char === '_'); } diff --git a/tests/unit/DNS/Validator/NameTest.php b/tests/unit/DNS/Validator/NameTest.php index 08807c8..35b7a4a 100644 --- a/tests/unit/DNS/Validator/NameTest.php +++ b/tests/unit/DNS/Validator/NameTest.php @@ -28,7 +28,7 @@ public function testValid(): void $this->assertTrue($validator->isValid($value), "Expected valid: {$value}"); } - // Allowed underscores in name + // Type that allows underscores in name $validator = new Name(Record::TYPE_SRV); $this->assertTrue($validator->isValid('example._tcp.com'), "Expected valid: example._tcp.com"); } @@ -42,13 +42,34 @@ public function testInvalid(): void ['value' => '', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], ['value' => str_repeat('a', 256) . '.com', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], ['value' => str_repeat('a', 64) . '.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_LENGTH], - ['value' => '-example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], - ['value' => 'example-.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], - ['value' => 'exa_mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], - ['value' => 'example..com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], - ['value' => '.example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], - ['value' => 'example.com..', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], - ['value' => 'exa mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS], + ['value' => '-example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'example-.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'exa_mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'example..com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => '.example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'example.com..', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'exa mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ]; + + foreach ($invalidValues as $value) { + $this->assertFalse($validator->isValid($value['value']), "Expected invalid: {$value['value']}"); + $this->assertSame($value['description'], $validator->getDescription()); + } + + // Type that allows underscores in name + $validator = new Name(Record::TYPE_TXT); + + $invalidValues = [ + ['value' => 123, 'description' => Name::FAILURE_REASON_GENERAL], + ['value' => '', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], + ['value' => str_repeat('a', 256) . '.com', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], + ['value' => str_repeat('a', 64) . '.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_LENGTH], + ['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' => 'example..com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE], + ['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], ]; foreach ($invalidValues as $value) { From 320801b4350f9cf26d706e87a683a99e53f29f30 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Fri, 5 Dec 2025 13:11:17 +0530 Subject: [PATCH 8/8] handle @ --- src/DNS/Validator/Name.php | 5 +++++ tests/unit/DNS/Validator/NameTest.php | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/DNS/Validator/Name.php b/src/DNS/Validator/Name.php index 4d72d80..fe883bf 100644 --- a/src/DNS/Validator/Name.php +++ b/src/DNS/Validator/Name.php @@ -54,6 +54,11 @@ public function isValid(mixed $name): bool return false; } + // Special case for referencing the zone origin + if ($name === '@') { + return true; + } + // If the name ends with '.', strip it (absolute FQDN); allow trailing '.'. $trimmed = (\substr($name, -1) === '.') ? \substr($name, 0, -1) : $name; $labels = \explode('.', $trimmed); diff --git a/tests/unit/DNS/Validator/NameTest.php b/tests/unit/DNS/Validator/NameTest.php index 35b7a4a..b38bde8 100644 --- a/tests/unit/DNS/Validator/NameTest.php +++ b/tests/unit/DNS/Validator/NameTest.php @@ -13,6 +13,7 @@ public function testValid(): void $validator = new Name(Record::TYPE_CNAME); $validValues = [ + '@', 'example', 'example.com', 'EXAMPLE.COM', @@ -42,6 +43,7 @@ public function testInvalid(): void ['value' => '', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], ['value' => str_repeat('a', 256) . '.com', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], ['value' => str_repeat('a', 64) . '.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_LENGTH], + ['value' => '@.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], ['value' => '-example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], ['value' => 'example-.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], ['value' => 'exa_mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], @@ -64,6 +66,7 @@ public function testInvalid(): void ['value' => '', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], ['value' => str_repeat('a', 256) . '.com', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], ['value' => str_repeat('a', 64) . '.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_LENGTH], + ['value' => '@.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE], ['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' => 'example..com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],