From b2b8d07c48c37b4ac52b9cb87c87e32e4e08e013 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 20 May 2026 09:36:08 +0530 Subject: [PATCH] Add DNS record rdata validation --- src/DNS/Message.php | 16 +++++++++++ src/DNS/Message/Record.php | 8 ++++++ tests/unit/DNS/Message/RecordTest.php | 31 ++++++++++++++++++++ tests/unit/DNS/MessageTest.php | 41 +++++++++++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/src/DNS/Message.php b/src/DNS/Message.php index 3633214..88a1587 100644 --- a/src/DNS/Message.php +++ b/src/DNS/Message.php @@ -278,6 +278,22 @@ public function encode(?int $maxSize = null): string return $header->encode() . substr($packet, Header::LENGTH); } + /** + * Validate all response records without encoding the message. + */ + public function validate(): void + { + foreach ([ + $this->answers, + $this->authority, + $this->additional, + ] as $records) { + foreach ($records as $record) { + $record->validateRdata(); + } + } + } + /** * @param list $records */ diff --git a/src/DNS/Message/Record.php b/src/DNS/Message/Record.php index fb7eb95..2499786 100644 --- a/src/DNS/Message/Record.php +++ b/src/DNS/Message/Record.php @@ -386,6 +386,14 @@ public function encode(string $packet = ''): string return $data; } + /** + * Validate RDATA for this record type without encoding the full record. + */ + public function validateRdata(): void + { + $this->encodeRdata(''); + } + /** * Encode RDATA based on record type. */ diff --git a/tests/unit/DNS/Message/RecordTest.php b/tests/unit/DNS/Message/RecordTest.php index f5ed386..b8605ad 100644 --- a/tests/unit/DNS/Message/RecordTest.php +++ b/tests/unit/DNS/Message/RecordTest.php @@ -513,6 +513,37 @@ class: Record::CLASS_IN, $this->assertSame(600, $decoded->ttl); } + public function testValidateRdataRejectsHostnameForARecord(): void + { + $record = new Record( + name: 'example.com', + type: Record::TYPE_A, + class: Record::CLASS_IN, + ttl: 300, + rdata: 'ns2.appwrite.zone' + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid IPv4 address: ns2.appwrite.zone'); + + $record->validateRdata(); + } + + public function testValidateRdataAcceptsHostnameForNsRecord(): void + { + $record = new Record( + name: 'example.com', + type: Record::TYPE_NS, + class: Record::CLASS_IN, + ttl: 300, + rdata: 'ns2.appwrite.zone' + ); + + $record->validateRdata(); + + $this->addToAssertionCount(1); + } + public function testConstructorTrimsWhitespaceFromName(): void { $record = new Record( diff --git a/tests/unit/DNS/MessageTest.php b/tests/unit/DNS/MessageTest.php index 1b44abd..0194d14 100644 --- a/tests/unit/DNS/MessageTest.php +++ b/tests/unit/DNS/MessageTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Utopia\DNS; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Utopia\DNS\Exception\Message\DecodingException; use Utopia\DNS\Exception\Message\PartialDecodingException; @@ -726,6 +727,46 @@ public function testExtremeAnswerTruncationPreservesAuthoritativeFlag(): void ); } + #[DataProvider('invalidSectionProvider')] + public function testValidateChecksAnswerAuthorityAndAdditionalRecords(string $invalidSection): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0x5508); + + $invalid = [ + new Record('ns2.appwrite.zone', Record::TYPE_A, Record::CLASS_IN, 300, 'ns2.appwrite.zone'), + ]; + $valid = [ + new Record('example.com', Record::TYPE_NS, Record::CLASS_IN, 300, 'ns2.appwrite.zone'), + ]; + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $invalidSection === 'answers' ? $invalid : $valid, + authority: $invalidSection === 'authority' ? $invalid : $valid, + additional: $invalidSection === 'additional' ? $invalid : $valid + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid IPv4 address: ns2.appwrite.zone'); + + $response->validate(); + } + + /** + * @return array> + */ + public static function invalidSectionProvider(): array + { + return [ + 'answers' => ['answers'], + 'authority' => ['authority'], + 'additional' => ['additional'], + ]; + } + /** * NODATA-style response: zero answers but populated authority. When the * authority section can't fit, it's dropped without setting TC — there