Skip to content

Commit dce3453

Browse files
authored
Merge pull request #25 from utopia-php/ser-650
Add Name & CAA validators
2 parents eea6b92 + 320801b commit dce3453

File tree

4 files changed

+374
-0
lines changed

4 files changed

+374
-0
lines changed

src/DNS/Validator/CAA.php

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
namespace Utopia\DNS\Validator;
4+
5+
use Utopia\Validator;
6+
7+
class CAA extends Validator
8+
{
9+
public const int CAA_FLAG_MIN = 0;
10+
11+
public const int CAA_FLAG_MAX = 255;
12+
13+
public const string FAILURE_REASON_INVALID_FLAGS = 'Flags must be a number between 0 and 255';
14+
15+
public const string FAILURE_REASON_INVALID_TAG = 'Tag must be a non-empty string';
16+
17+
public const string FAILURE_REASON_INVALID_VALUE = 'Value must be a non-empty string and must be enclosed in quotes';
18+
19+
public const string FAILURE_REASON_INVALID_FORMAT = 'CAA record must be in the format <flags> <tag> "<value>"';
20+
21+
public string $reason = '';
22+
23+
/**
24+
* Check if the provided value matches the CAA record format
25+
*
26+
* @param mixed $data
27+
* @return bool
28+
*/
29+
public function isValid(mixed $data): bool
30+
{
31+
if (!is_string($data)) {
32+
$this->reason = self::FAILURE_REASON_INVALID_FORMAT;
33+
return false;
34+
}
35+
36+
$parts = explode(" ", $data, 3);
37+
38+
if (count($parts) !== 3) {
39+
$this->reason = self::FAILURE_REASON_INVALID_FORMAT;
40+
return false;
41+
}
42+
43+
$flags = $parts[0];
44+
$tag = $parts[1];
45+
$value = $parts[2];
46+
47+
// Check flags is a number
48+
if (!is_numeric($flags)) {
49+
$this->reason = self::FAILURE_REASON_INVALID_FLAGS;
50+
return false;
51+
}
52+
53+
$flags = (int) $flags;
54+
55+
// Check flags is within the allowed range
56+
if ($flags < self::CAA_FLAG_MIN || $flags > self::CAA_FLAG_MAX) {
57+
$this->reason = self::FAILURE_REASON_INVALID_FLAGS;
58+
return false;
59+
}
60+
61+
// Check tag is not empty
62+
if (strlen($tag) === 0) {
63+
$this->reason = self::FAILURE_REASON_INVALID_TAG;
64+
return false;
65+
}
66+
67+
// Check value is not empty and starts with " and ends with "
68+
if (strlen($value) === 0 || $value[0] !== '"' || $value[strlen($value) - 1] !== '"') {
69+
$this->reason = self::FAILURE_REASON_INVALID_VALUE;
70+
return false;
71+
}
72+
73+
$value = substr($value, 1, strlen($value) - 2);
74+
75+
// Check value is not empty after removing the quotes
76+
if (strlen($value) === 0) {
77+
$this->reason = self::FAILURE_REASON_INVALID_VALUE;
78+
return false;
79+
}
80+
81+
// All checks passed
82+
return true;
83+
}
84+
85+
public function getDescription(): string
86+
{
87+
if (!empty($this->reason)) {
88+
return $this->reason;
89+
}
90+
91+
return self::FAILURE_REASON_INVALID_FORMAT;
92+
}
93+
94+
/**
95+
* Is array
96+
*
97+
* Function will return true if object is array.
98+
*
99+
* @return bool
100+
*/
101+
public function isArray(): bool
102+
{
103+
return false;
104+
}
105+
106+
/**
107+
* Get Type
108+
*
109+
* Returns validator type.
110+
*
111+
* @return string
112+
*/
113+
public function getType(): string
114+
{
115+
return self::TYPE_STRING;
116+
}
117+
}

src/DNS/Validator/Name.php

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
namespace Utopia\DNS\Validator;
4+
5+
use Utopia\DNS\Message\Domain;
6+
use Utopia\DNS\Message\Record;
7+
use Utopia\Validator;
8+
9+
class Name extends Validator
10+
{
11+
private const array RECORD_TYPES_WITH_UNDERSCORE_IN_NAME = [Record::TYPE_SRV, Record::TYPE_TXT];
12+
13+
public const int LABEL_MAX_LENGTH = 63;
14+
15+
public const string FAILURE_REASON_INVALID_LABEL_LENGTH = 'Label must be between 1 and 63 characters long';
16+
17+
public const string FAILURE_REASON_INVALID_NAME_LENGTH = 'Name must be between 1 and 255 characters long';
18+
19+
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';
20+
21+
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';
22+
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';
24+
25+
public string $reason = '';
26+
27+
private int $recordType;
28+
29+
public function __construct(int $recordType)
30+
{
31+
$this->recordType = $recordType;
32+
}
33+
34+
/**
35+
* Check if the provided value matches the Name record format
36+
*
37+
* @param mixed $name
38+
* @return bool
39+
*/
40+
public function isValid(mixed $name): bool
41+
{
42+
if (!\is_string($name)) {
43+
$this->reason = self::FAILURE_REASON_GENERAL;
44+
return false;
45+
}
46+
47+
// DNS names are made up of labels separated by dots.
48+
// Each label: 1-63 chars, letters, digits, hyphens, can't start/end w/ hyphen.
49+
// 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.
51+
52+
if (\strlen($name) < 1 || \strlen($name) > Domain::MAX_DOMAIN_NAME_LEN) {
53+
$this->reason = self::FAILURE_REASON_INVALID_NAME_LENGTH;
54+
return false;
55+
}
56+
57+
// Special case for referencing the zone origin
58+
if ($name === '@') {
59+
return true;
60+
}
61+
62+
// If the name ends with '.', strip it (absolute FQDN); allow trailing '.'.
63+
$trimmed = (\substr($name, -1) === '.') ? \substr($name, 0, -1) : $name;
64+
$labels = \explode('.', $trimmed);
65+
66+
$isUnderscoreAllowed = \in_array($this->recordType, self::RECORD_TYPES_WITH_UNDERSCORE_IN_NAME);
67+
68+
foreach ($labels as $label) {
69+
if ($label === '') {
70+
$this->reason = $isUnderscoreAllowed ? self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE : self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE;
71+
return false;
72+
}
73+
74+
if (\strlen($label) > self::LABEL_MAX_LENGTH) {
75+
$this->reason = self::FAILURE_REASON_INVALID_LABEL_LENGTH;
76+
return false;
77+
}
78+
79+
// RFC: Only a-z 0-9 -, can't start or end with '-'
80+
// May contain '_' if the record type allows it.
81+
$len = \strlen($label);
82+
// Check label contains only allowed chars
83+
for ($i = 0; $i < $len; ++$i) {
84+
if (!$this->isValidCharacter($label[$i], $i === 0 || $i === $len - 1, $isUnderscoreAllowed)) {
85+
$this->reason = $isUnderscoreAllowed ? self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE : self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE;
86+
return false;
87+
}
88+
}
89+
}
90+
91+
return true;
92+
}
93+
94+
private function isValidCharacter(string $char, bool $isFirstOrLast, bool $isUnderscoreAllowed): bool
95+
{
96+
if ($isFirstOrLast) {
97+
return \ctype_alnum($char) || ($isUnderscoreAllowed && $char === '_');
98+
}
99+
return \ctype_alnum($char) || $char === '-' || ($isUnderscoreAllowed && $char === '_');
100+
}
101+
102+
/**
103+
* @inheritDoc
104+
*/
105+
public function getDescription(): string
106+
{
107+
if (!empty($this->reason)) {
108+
return $this->reason;
109+
}
110+
111+
return self::FAILURE_REASON_GENERAL;
112+
}
113+
114+
/**
115+
* @inheritDoc
116+
*/
117+
public function getType(): string
118+
{
119+
return self::TYPE_STRING;
120+
}
121+
122+
/**
123+
* @inheritDoc
124+
*/
125+
public function isArray(): bool
126+
{
127+
return false;
128+
}
129+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace Tests\Unit\Utopia\DNS\Validator;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Utopia\DNS\Validator\CAA;
7+
8+
final class CAATest extends TestCase
9+
{
10+
public function testValid(): void
11+
{
12+
$validator = new CAA();
13+
14+
$validValues = [
15+
'0 issue "letsencrypt.org"',
16+
'128 issuewild "certainly.com;account=123456;validationmethods=dns-01"',
17+
'0 issuewild "certainly.com"',
18+
'0 iodef "mailto:security@example.com"',
19+
'0 issue ";"',
20+
'0 issue "certainly.com; validationmethods=dns-01"',
21+
];
22+
23+
foreach ($validValues as $value) {
24+
$this->assertTrue($validator->isValid($value), "Expected valid: {$value}");
25+
}
26+
}
27+
28+
public function testInvalid(): void
29+
{
30+
$validator = new CAA();
31+
32+
$invalidValues = [
33+
['value' => 'issue "letsencrypt.org"', 'description' => CAA::FAILURE_REASON_INVALID_FORMAT],
34+
['value' => '0 ""', 'description' => CAA::FAILURE_REASON_INVALID_FORMAT],
35+
['value' => '256 issue "letsencrypt.org"', 'description' => CAA::FAILURE_REASON_INVALID_FLAGS],
36+
['value' => '0 issue letsencrypt.org', 'description' => CAA::FAILURE_REASON_INVALID_VALUE],
37+
['value' => '0 issue ""', 'description' => CAA::FAILURE_REASON_INVALID_VALUE],
38+
];
39+
40+
foreach ($invalidValues as $invalidValue) {
41+
$this->assertFalse($validator->isValid($invalidValue['value']), "Expected invalid: {$invalidValue['value']}");
42+
$this->assertSame($invalidValue['description'], $validator->getDescription());
43+
}
44+
}
45+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
namespace Tests\Unit\Utopia\DNS\Validator;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Utopia\DNS\Message\Record;
7+
use Utopia\DNS\Validator\Name;
8+
9+
final class NameTest extends TestCase
10+
{
11+
public function testValid(): void
12+
{
13+
$validator = new Name(Record::TYPE_CNAME);
14+
15+
$validValues = [
16+
'@',
17+
'example',
18+
'example.com',
19+
'EXAMPLE.COM',
20+
'a-b.com',
21+
'a123.example-domain.org',
22+
'xn--d1acufc.xn--p1ai',
23+
'123.com',
24+
'example.com.',
25+
str_repeat('a', 63) . '.com',
26+
];
27+
28+
foreach ($validValues as $value) {
29+
$this->assertTrue($validator->isValid($value), "Expected valid: {$value}");
30+
}
31+
32+
// Type that allows underscores in name
33+
$validator = new Name(Record::TYPE_SRV);
34+
$this->assertTrue($validator->isValid('example._tcp.com'), "Expected valid: example._tcp.com");
35+
}
36+
37+
public function testInvalid(): void
38+
{
39+
$validator = new Name(Record::TYPE_CNAME);
40+
41+
$invalidValues = [
42+
['value' => 123, 'description' => Name::FAILURE_REASON_GENERAL],
43+
['value' => '', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH],
44+
['value' => str_repeat('a', 256) . '.com', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH],
45+
['value' => str_repeat('a', 64) . '.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_LENGTH],
46+
['value' => '@.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE],
47+
['value' => '-example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE],
48+
['value' => 'example-.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE],
49+
['value' => 'exa_mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE],
50+
['value' => 'example..com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE],
51+
['value' => '.example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE],
52+
['value' => 'example.com..', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE],
53+
['value' => 'exa mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE],
54+
];
55+
56+
foreach ($invalidValues as $value) {
57+
$this->assertFalse($validator->isValid($value['value']), "Expected invalid: {$value['value']}");
58+
$this->assertSame($value['description'], $validator->getDescription());
59+
}
60+
61+
// Type that allows underscores in name
62+
$validator = new Name(Record::TYPE_TXT);
63+
64+
$invalidValues = [
65+
['value' => 123, 'description' => Name::FAILURE_REASON_GENERAL],
66+
['value' => '', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH],
67+
['value' => str_repeat('a', 256) . '.com', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH],
68+
['value' => str_repeat('a', 64) . '.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_LENGTH],
69+
['value' => '@.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
70+
['value' => '-example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
71+
['value' => 'example-.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
72+
['value' => 'example..com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
73+
['value' => '.example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
74+
['value' => 'example.com..', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
75+
['value' => 'exa mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE],
76+
];
77+
78+
foreach ($invalidValues as $value) {
79+
$this->assertFalse($validator->isValid($value['value']), "Expected invalid: {$value['value']}");
80+
$this->assertSame($value['description'], $validator->getDescription());
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)