From 08796c38ed6c677c0bdf9a9c6ae4ad6b8ff3e3d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 05:39:31 +0000 Subject: [PATCH 1/6] Add DNS over HTTPS (DoH) support with RFC 8484 compliance Implements DoH client and resolvers for secure DNS queries over HTTPS: - DoHClient: Core client supporting both GET and POST methods per RFC 8484 - DoH resolver base class for custom DoH endpoints - CloudflareDoH: Pre-configured Cloudflare DoH resolver - GoogleDoH: Pre-configured Google DoH resolver Includes comprehensive unit and E2E tests for all new functionality. https://claude.ai/code/session_01W9EN976RGtJV3B3Lu2DYrB --- src/DNS/DoHClient.php | 210 +++++++++++++++++++ src/DNS/Resolver/CloudflareDoH.php | 46 ++++ src/DNS/Resolver/DoH.php | 66 ++++++ src/DNS/Resolver/GoogleDoH.php | 49 +++++ tests/e2e/DNS/Resolver/CloudflareDoHTest.php | 129 ++++++++++++ tests/e2e/DNS/Resolver/DoHTest.php | 123 +++++++++++ tests/e2e/DNS/Resolver/GoogleDoHTest.php | 129 ++++++++++++ tests/unit/DNS/DoHClientTest.php | 55 +++++ 8 files changed, 807 insertions(+) create mode 100644 src/DNS/DoHClient.php create mode 100644 src/DNS/Resolver/CloudflareDoH.php create mode 100644 src/DNS/Resolver/DoH.php create mode 100644 src/DNS/Resolver/GoogleDoH.php create mode 100644 tests/e2e/DNS/Resolver/CloudflareDoHTest.php create mode 100644 tests/e2e/DNS/Resolver/DoHTest.php create mode 100644 tests/e2e/DNS/Resolver/GoogleDoHTest.php create mode 100644 tests/unit/DNS/DoHClientTest.php diff --git a/src/DNS/DoHClient.php b/src/DNS/DoHClient.php new file mode 100644 index 0000000..fae5778 --- /dev/null +++ b/src/DNS/DoHClient.php @@ -0,0 +1,210 @@ +method === self::METHOD_GET) { + return $this->queryGet($message); + } + + return $this->queryPost($message); + } + + /** + * Send a DNS query using HTTP POST method + * + * RFC 8484 Section 4.1: POST request with application/dns-message body + * + * @param Message $message The DNS query message + * @return Message The DNS response message + */ + protected function queryPost(Message $message): Message + { + $packet = $message->encode(); + + $ch = curl_init(); + + curl_setopt_array($ch, [ + CURLOPT_URL => $this->endpoint, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $packet, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/dns-message', + 'Accept: application/dns-message', + ], + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + $errno = curl_errno($ch); + + curl_close($ch); + + if ($errno !== 0) { + throw new Exception("DoH request failed: $error (Error code: $errno)"); + } + + if ($httpCode !== 200) { + throw new Exception("DoH server returned HTTP $httpCode"); + } + + if (!is_string($response) || $response === '') { + throw new Exception('Empty response received from DoH server'); + } + + return $this->decodeResponse($message, $response); + } + + /** + * Send a DNS query using HTTP GET method + * + * RFC 8484 Section 4.1: GET request with base64url-encoded dns parameter + * + * @param Message $message The DNS query message + * @return Message The DNS response message + */ + protected function queryGet(Message $message): Message + { + $packet = $message->encode(); + $encoded = $this->base64UrlEncode($packet); + + $separator = str_contains($this->endpoint, '?') ? '&' : '?'; + $url = $this->endpoint . $separator . 'dns=' . $encoded; + + $ch = curl_init(); + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => [ + 'Accept: application/dns-message', + ], + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + $errno = curl_errno($ch); + + curl_close($ch); + + if ($errno !== 0) { + throw new Exception("DoH request failed: $error (Error code: $errno)"); + } + + if ($httpCode !== 200) { + throw new Exception("DoH server returned HTTP $httpCode"); + } + + if (!is_string($response) || $response === '') { + throw new Exception('Empty response received from DoH server'); + } + + return $this->decodeResponse($message, $response); + } + + /** + * Decode the DNS response and validate the transaction ID + * + * @param Message $query Original query message + * @param string $payload Raw response data + * @return Message Decoded response message + */ + protected function decodeResponse(Message $query, string $payload): Message + { + $response = Message::decode($payload); + + if ($response->header->id !== $query->header->id) { + throw new Exception( + "Mismatched DNS transaction ID. Expected {$query->header->id}, got {$response->header->id}" + ); + } + + return $response; + } + + /** + * Encode data using base64url encoding (RFC 4648 Section 5) + * + * This is required for the GET method as per RFC 8484 Section 4.1 + * + * @param string $data Binary data to encode + * @return string Base64url-encoded string (no padding) + */ + protected function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * Get the DoH endpoint URL + * + * @return string The endpoint URL + */ + public function getEndpoint(): string + { + return $this->endpoint; + } + + /** + * Get the HTTP method being used + * + * @return string The HTTP method (GET or POST) + */ + public function getMethod(): string + { + return $this->method; + } +} diff --git a/src/DNS/Resolver/CloudflareDoH.php b/src/DNS/Resolver/CloudflareDoH.php new file mode 100644 index 0000000..009e842 --- /dev/null +++ b/src/DNS/Resolver/CloudflareDoH.php @@ -0,0 +1,46 @@ +endpoint)"; + } +} diff --git a/src/DNS/Resolver/DoH.php b/src/DNS/Resolver/DoH.php new file mode 100644 index 0000000..c39c0cf --- /dev/null +++ b/src/DNS/Resolver/DoH.php @@ -0,0 +1,66 @@ +endpoint = $endpoint; + $this->client = new DoHClient($endpoint, $timeout, $method); + } + + /** + * Resolve DNS query by forwarding to the DoH server + * + * @param Message $query The DNS query message + * @return Message The DNS response message + */ + public function resolve(Message $query): Message + { + return $this->client->query($query); + } + + /** + * Get the name of the resolver + * + * @return string The resolver name + */ + public function getName(): string + { + return "DoH ($this->endpoint)"; + } + + /** + * Get the DoH client instance + * + * @return DoHClient The client instance + */ + public function getClient(): DoHClient + { + return $this->client; + } +} diff --git a/src/DNS/Resolver/GoogleDoH.php b/src/DNS/Resolver/GoogleDoH.php new file mode 100644 index 0000000..242bb0d --- /dev/null +++ b/src/DNS/Resolver/GoogleDoH.php @@ -0,0 +1,49 @@ +endpoint)"; + } +} diff --git a/tests/e2e/DNS/Resolver/CloudflareDoHTest.php b/tests/e2e/DNS/Resolver/CloudflareDoHTest.php new file mode 100644 index 0000000..ee59c0e --- /dev/null +++ b/tests/e2e/DNS/Resolver/CloudflareDoHTest.php @@ -0,0 +1,129 @@ +resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveGoogleAWithGet(): void + { + $resolver = new CloudflareDoH(useBackup: false, method: DoHClient::METHOD_GET); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveGoogleAAAA(): void + { + $resolver = new CloudflareDoH(); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_AAAA + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_AAAA, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)); + } + + public function testResolveMXRecord(): void + { + $resolver = new CloudflareDoH(); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_MX + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_MX, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotNull($record->priority); + } + + public function testResolveWithBackupEndpoint(): void + { + $resolver = new CloudflareDoH(useBackup: true); + + $this->assertSame(CloudflareDoH::ENDPOINT_BACKUP, $resolver->getClient()->getEndpoint()); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + } + + public function testGetName(): void + { + $resolver = new CloudflareDoH(); + $this->assertStringContainsString('Cloudflare DoH', $resolver->getName()); + $this->assertStringContainsString(CloudflareDoH::ENDPOINT_PRIMARY, $resolver->getName()); + + $resolverBackup = new CloudflareDoH(useBackup: true); + $this->assertStringContainsString(CloudflareDoH::ENDPOINT_BACKUP, $resolverBackup->getName()); + } + + public function testEndpointConstants(): void + { + $this->assertSame('https://cloudflare-dns.com/dns-query', CloudflareDoH::ENDPOINT_PRIMARY); + $this->assertSame('https://one.one.one.one/dns-query', CloudflareDoH::ENDPOINT_BACKUP); + } +} diff --git a/tests/e2e/DNS/Resolver/DoHTest.php b/tests/e2e/DNS/Resolver/DoHTest.php new file mode 100644 index 0000000..b284e9b --- /dev/null +++ b/tests/e2e/DNS/Resolver/DoHTest.php @@ -0,0 +1,123 @@ +resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('example.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveWithGetMethod(): void + { + $resolver = new DoH('https://cloudflare-dns.com/dns-query', 5, DoHClient::METHOD_GET); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + } + + public function testResolveWithPostMethod(): void + { + $resolver = new DoH('https://cloudflare-dns.com/dns-query', 5, DoHClient::METHOD_POST); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + } + + public function testGetName(): void + { + $resolver = new DoH('https://custom-dns.example.com/dns-query'); + $this->assertSame('DoH (https://custom-dns.example.com/dns-query)', $resolver->getName()); + } + + public function testGetClient(): void + { + $resolver = new DoH('https://cloudflare-dns.com/dns-query', 10, DoHClient::METHOD_GET); + $client = $resolver->getClient(); + + $this->assertInstanceOf(DoHClient::class, $client); + $this->assertSame('https://cloudflare-dns.com/dns-query', $client->getEndpoint()); + $this->assertSame(DoHClient::METHOD_GET, $client->getMethod()); + } + + public function testResolveTXTRecord(): void + { + $resolver = new DoH('https://cloudflare-dns.com/dns-query'); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_TXT + ) + )); + + $this->assertNotEmpty($response->answers); + + $hasTxt = false; + foreach ($response->answers as $record) { + if ($record->type === Record::TYPE_TXT) { + $hasTxt = true; + break; + } + } + $this->assertTrue($hasTxt, 'Response should contain TXT records'); + } + + public function testResolveNSRecord(): void + { + $resolver = new DoH('https://cloudflare-dns.com/dns-query'); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_NS + ) + )); + + $this->assertNotEmpty($response->answers); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_NS, $record->type); + $this->assertSame('google.com', $record->name); + } +} diff --git a/tests/e2e/DNS/Resolver/GoogleDoHTest.php b/tests/e2e/DNS/Resolver/GoogleDoHTest.php new file mode 100644 index 0000000..b099085 --- /dev/null +++ b/tests/e2e/DNS/Resolver/GoogleDoHTest.php @@ -0,0 +1,129 @@ +resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveGoogleAWithGet(): void + { + $resolver = new GoogleDoH(useBackup: false, method: DoHClient::METHOD_GET); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_A, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)); + } + + public function testResolveGoogleAAAA(): void + { + $resolver = new GoogleDoH(); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_AAAA + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_AAAA, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotFalse(filter_var($record->rdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)); + } + + public function testResolveMXRecord(): void + { + $resolver = new GoogleDoH(); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'google.com', + type: Record::TYPE_MX + ) + )); + + $this->assertNotEmpty($response->answers); + $this->assertInstanceOf(Record::class, $response->answers[0] ?? null); + + /** @var Record $record */ + $record = $response->answers[0]; + $this->assertSame(Record::TYPE_MX, $record->type); + $this->assertSame('google.com', $record->name); + $this->assertNotNull($record->priority); + } + + public function testResolveWithBackupEndpoint(): void + { + $resolver = new GoogleDoH(useBackup: true); + + $this->assertSame(GoogleDoH::ENDPOINT_BACKUP, $resolver->getClient()->getEndpoint()); + + $response = $resolver->resolve(Message::query( + new Question( + name: 'example.com', + type: Record::TYPE_A + ) + )); + + $this->assertNotEmpty($response->answers); + } + + public function testGetName(): void + { + $resolver = new GoogleDoH(); + $this->assertStringContainsString('Google DoH', $resolver->getName()); + $this->assertStringContainsString(GoogleDoH::ENDPOINT_PRIMARY, $resolver->getName()); + + $resolverBackup = new GoogleDoH(useBackup: true); + $this->assertStringContainsString(GoogleDoH::ENDPOINT_BACKUP, $resolverBackup->getName()); + } + + public function testEndpointConstants(): void + { + $this->assertSame('https://dns.google/dns-query', GoogleDoH::ENDPOINT_PRIMARY); + $this->assertSame('https://dns.google/dns-query', GoogleDoH::ENDPOINT_BACKUP); + } +} diff --git a/tests/unit/DNS/DoHClientTest.php b/tests/unit/DNS/DoHClientTest.php new file mode 100644 index 0000000..940e2bf --- /dev/null +++ b/tests/unit/DNS/DoHClientTest.php @@ -0,0 +1,55 @@ +expectException(\Exception::class); + $this->expectExceptionMessage('Invalid DoH endpoint URL.'); + + new DoHClient('not-a-valid-url'); + } + + public function testConstructorValidatesMethod(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid HTTP method. Use GET or POST.'); + + new DoHClient('https://cloudflare-dns.com/dns-query', 5, 'PUT'); + } + + public function testConstructorAcceptsValidEndpoint(): void + { + $client = new DoHClient('https://cloudflare-dns.com/dns-query'); + + $this->assertSame('https://cloudflare-dns.com/dns-query', $client->getEndpoint()); + $this->assertSame(DoHClient::METHOD_POST, $client->getMethod()); + } + + public function testConstructorAcceptsGetMethod(): void + { + $client = new DoHClient('https://dns.google/dns-query', 5, DoHClient::METHOD_GET); + + $this->assertSame('https://dns.google/dns-query', $client->getEndpoint()); + $this->assertSame(DoHClient::METHOD_GET, $client->getMethod()); + } + + public function testConstructorAcceptsPostMethod(): void + { + $client = new DoHClient('https://dns.google/dns-query', 5, DoHClient::METHOD_POST); + + $this->assertSame('https://dns.google/dns-query', $client->getEndpoint()); + $this->assertSame(DoHClient::METHOD_POST, $client->getMethod()); + } + + public function testMethodConstants(): void + { + $this->assertSame('GET', DoHClient::METHOD_GET); + $this->assertSame('POST', DoHClient::METHOD_POST); + } +} From 84186dd7e69b2e46395b03d3bccb2e3845ea190a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 06:10:01 +0000 Subject: [PATCH 2/6] Enable external DNS resolution in CI docker container Update docker-compose.yml to use public DNS servers (8.8.8.8, 1.1.1.1) instead of 127.0.0.1 to allow DoH E2E tests to resolve external hostnames like cloudflare-dns.com and dns.google. https://claude.ai/code/session_01W9EN976RGtJV3B3Lu2DYrB --- docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7c79bf5..cfddca0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,8 @@ services: - ./tests:/usr/src/code/tests - ./phpunit.xml:/usr/src/code/phpunit.xml dns: - - 127.0.0.1 + - 8.8.8.8 + - 1.1.1.1 networks: - dns ports: From bbe385f9c8642a97aed1eaf35c4575dae227884e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 6 May 2026 04:19:57 +0000 Subject: [PATCH 3/6] refactor: rename DoH classes to Http namespace, split connect/total timeouts - Move DoHClient to Utopia\DNS\Http\Client - Rename Resolver\DoH to Resolver\Http - Move CloudflareDoH/GoogleDoH under Resolver\Http\{Cloudflare,Google} - Split single $timeout into $timeout (total) and $connectTimeout (connect) for better worker exhaustion control under load Co-Authored-By: Claude Opus 4.7 (1M context) --- src/DNS/{DoHClient.php => Http/Client.php} | 19 ++++++----- src/DNS/Resolver/{DoH.php => Http.php} | 26 +++++++------- .../Cloudflare.php} | 21 +++++++----- .../{GoogleDoH.php => Http/Google.php} | 21 +++++++----- .../CloudflareTest.php} | 34 +++++++++---------- .../GoogleTest.php} | 34 +++++++++---------- .../Resolver/{DoHTest.php => HttpTest.php} | 27 +++++++-------- .../ClientTest.php} | 26 +++++++------- 8 files changed, 109 insertions(+), 99 deletions(-) rename src/DNS/{DoHClient.php => Http/Client.php} (92%) rename src/DNS/Resolver/{DoH.php => Http.php} (64%) rename src/DNS/Resolver/{CloudflareDoH.php => Http/Cloudflare.php} (61%) rename src/DNS/Resolver/{GoogleDoH.php => Http/Google.php} (66%) rename tests/e2e/DNS/Resolver/{CloudflareDoHTest.php => Http/CloudflareTest.php} (77%) rename tests/e2e/DNS/Resolver/{GoogleDoHTest.php => Http/GoogleTest.php} (78%) rename tests/e2e/DNS/Resolver/{DoHTest.php => HttpTest.php} (75%) rename tests/unit/DNS/{DoHClientTest.php => Http/ClientTest.php} (55%) diff --git a/src/DNS/DoHClient.php b/src/DNS/Http/Client.php similarity index 92% rename from src/DNS/DoHClient.php rename to src/DNS/Http/Client.php index fae5778..859079c 100644 --- a/src/DNS/DoHClient.php +++ b/src/DNS/Http/Client.php @@ -1,30 +1,33 @@ $packet, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeout, - CURLOPT_CONNECTTIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, CURLOPT_HTTPHEADER => [ 'Content-Type: application/dns-message', 'Accept: application/dns-message', @@ -125,7 +128,7 @@ protected function queryGet(Message $message): Message CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeout, - CURLOPT_CONNECTTIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, CURLOPT_HTTPHEADER => [ 'Accept: application/dns-message', ], @@ -178,7 +181,7 @@ protected function decodeResponse(Message $query, string $payload): Message /** * Encode data using base64url encoding (RFC 4648 Section 5) * - * This is required for the GET method as per RFC 8484 Section 4.1 + * Required for the GET method as per RFC 8484 Section 4.1 * * @param string $data Binary data to encode * @return string Base64url-encoded string (no padding) diff --git a/src/DNS/Resolver/DoH.php b/src/DNS/Resolver/Http.php similarity index 64% rename from src/DNS/Resolver/DoH.php rename to src/DNS/Resolver/Http.php index c39c0cf..a7d22c6 100644 --- a/src/DNS/Resolver/DoH.php +++ b/src/DNS/Resolver/Http.php @@ -2,35 +2,37 @@ namespace Utopia\DNS\Resolver; -use Utopia\DNS\DoHClient; +use Utopia\DNS\Http\Client; use Utopia\DNS\Message; use Utopia\DNS\Resolver; /** - * DNS over HTTPS (DoH) Resolver + * DNS over HTTPS Resolver * * A resolver that forwards DNS queries to a DoH server over HTTPS. * Implements RFC 8484 for DNS queries over HTTP/HTTPS. */ -class DoH implements Resolver +class Http implements Resolver { - protected DoHClient $client; + protected Client $client; protected string $endpoint; /** - * Create a new DoH resolver + * Create a new HTTP (DoH) resolver * * @param string $endpoint DoH endpoint URL (e.g., https://cloudflare-dns.com/dns-query) - * @param int $timeout Request timeout in seconds + * @param int $timeout Total request timeout in seconds + * @param int $connectTimeout Connection timeout in seconds * @param string $method HTTP method to use (GET or POST) */ public function __construct( string $endpoint, int $timeout = 5, - string $method = DoHClient::METHOD_POST + int $connectTimeout = 2, + string $method = Client::METHOD_POST ) { $this->endpoint = $endpoint; - $this->client = new DoHClient($endpoint, $timeout, $method); + $this->client = new Client($endpoint, $timeout, $connectTimeout, $method); } /** @@ -51,15 +53,15 @@ public function resolve(Message $query): Message */ public function getName(): string { - return "DoH ($this->endpoint)"; + return "HTTP ($this->endpoint)"; } /** - * Get the DoH client instance + * Get the underlying HTTP client * - * @return DoHClient The client instance + * @return Client The client instance */ - public function getClient(): DoHClient + public function getClient(): Client { return $this->client; } diff --git a/src/DNS/Resolver/CloudflareDoH.php b/src/DNS/Resolver/Http/Cloudflare.php similarity index 61% rename from src/DNS/Resolver/CloudflareDoH.php rename to src/DNS/Resolver/Http/Cloudflare.php index 009e842..98fcf88 100644 --- a/src/DNS/Resolver/CloudflareDoH.php +++ b/src/DNS/Resolver/Http/Cloudflare.php @@ -1,11 +1,12 @@ endpoint)"; + return "Cloudflare HTTP ($this->endpoint)"; } } diff --git a/src/DNS/Resolver/GoogleDoH.php b/src/DNS/Resolver/Http/Google.php similarity index 66% rename from src/DNS/Resolver/GoogleDoH.php rename to src/DNS/Resolver/Http/Google.php index 242bb0d..886c067 100644 --- a/src/DNS/Resolver/GoogleDoH.php +++ b/src/DNS/Resolver/Http/Google.php @@ -1,11 +1,12 @@ endpoint)"; + return "Google HTTP ($this->endpoint)"; } } diff --git a/tests/e2e/DNS/Resolver/CloudflareDoHTest.php b/tests/e2e/DNS/Resolver/Http/CloudflareTest.php similarity index 77% rename from tests/e2e/DNS/Resolver/CloudflareDoHTest.php rename to tests/e2e/DNS/Resolver/Http/CloudflareTest.php index ee59c0e..31b91d9 100644 --- a/tests/e2e/DNS/Resolver/CloudflareDoHTest.php +++ b/tests/e2e/DNS/Resolver/Http/CloudflareTest.php @@ -1,19 +1,19 @@ resolve(Message::query( new Question( @@ -34,7 +34,7 @@ public function testResolveGoogleAWithPost(): void public function testResolveGoogleAWithGet(): void { - $resolver = new CloudflareDoH(useBackup: false, method: DoHClient::METHOD_GET); + $resolver = new Cloudflare(useBackup: false, method: Client::METHOD_GET); $response = $resolver->resolve(Message::query( new Question( @@ -55,7 +55,7 @@ public function testResolveGoogleAWithGet(): void public function testResolveGoogleAAAA(): void { - $resolver = new CloudflareDoH(); + $resolver = new Cloudflare(); $response = $resolver->resolve(Message::query( new Question( @@ -76,7 +76,7 @@ public function testResolveGoogleAAAA(): void public function testResolveMXRecord(): void { - $resolver = new CloudflareDoH(); + $resolver = new Cloudflare(); $response = $resolver->resolve(Message::query( new Question( @@ -97,9 +97,9 @@ public function testResolveMXRecord(): void public function testResolveWithBackupEndpoint(): void { - $resolver = new CloudflareDoH(useBackup: true); + $resolver = new Cloudflare(useBackup: true); - $this->assertSame(CloudflareDoH::ENDPOINT_BACKUP, $resolver->getClient()->getEndpoint()); + $this->assertSame(Cloudflare::ENDPOINT_BACKUP, $resolver->getClient()->getEndpoint()); $response = $resolver->resolve(Message::query( new Question( @@ -113,17 +113,17 @@ public function testResolveWithBackupEndpoint(): void public function testGetName(): void { - $resolver = new CloudflareDoH(); - $this->assertStringContainsString('Cloudflare DoH', $resolver->getName()); - $this->assertStringContainsString(CloudflareDoH::ENDPOINT_PRIMARY, $resolver->getName()); + $resolver = new Cloudflare(); + $this->assertStringContainsString('Cloudflare HTTP', $resolver->getName()); + $this->assertStringContainsString(Cloudflare::ENDPOINT_PRIMARY, $resolver->getName()); - $resolverBackup = new CloudflareDoH(useBackup: true); - $this->assertStringContainsString(CloudflareDoH::ENDPOINT_BACKUP, $resolverBackup->getName()); + $resolverBackup = new Cloudflare(useBackup: true); + $this->assertStringContainsString(Cloudflare::ENDPOINT_BACKUP, $resolverBackup->getName()); } public function testEndpointConstants(): void { - $this->assertSame('https://cloudflare-dns.com/dns-query', CloudflareDoH::ENDPOINT_PRIMARY); - $this->assertSame('https://one.one.one.one/dns-query', CloudflareDoH::ENDPOINT_BACKUP); + $this->assertSame('https://cloudflare-dns.com/dns-query', Cloudflare::ENDPOINT_PRIMARY); + $this->assertSame('https://one.one.one.one/dns-query', Cloudflare::ENDPOINT_BACKUP); } } diff --git a/tests/e2e/DNS/Resolver/GoogleDoHTest.php b/tests/e2e/DNS/Resolver/Http/GoogleTest.php similarity index 78% rename from tests/e2e/DNS/Resolver/GoogleDoHTest.php rename to tests/e2e/DNS/Resolver/Http/GoogleTest.php index b099085..739598f 100644 --- a/tests/e2e/DNS/Resolver/GoogleDoHTest.php +++ b/tests/e2e/DNS/Resolver/Http/GoogleTest.php @@ -1,19 +1,19 @@ resolve(Message::query( new Question( @@ -34,7 +34,7 @@ public function testResolveGoogleAWithPost(): void public function testResolveGoogleAWithGet(): void { - $resolver = new GoogleDoH(useBackup: false, method: DoHClient::METHOD_GET); + $resolver = new Google(useBackup: false, method: Client::METHOD_GET); $response = $resolver->resolve(Message::query( new Question( @@ -55,7 +55,7 @@ public function testResolveGoogleAWithGet(): void public function testResolveGoogleAAAA(): void { - $resolver = new GoogleDoH(); + $resolver = new Google(); $response = $resolver->resolve(Message::query( new Question( @@ -76,7 +76,7 @@ public function testResolveGoogleAAAA(): void public function testResolveMXRecord(): void { - $resolver = new GoogleDoH(); + $resolver = new Google(); $response = $resolver->resolve(Message::query( new Question( @@ -97,9 +97,9 @@ public function testResolveMXRecord(): void public function testResolveWithBackupEndpoint(): void { - $resolver = new GoogleDoH(useBackup: true); + $resolver = new Google(useBackup: true); - $this->assertSame(GoogleDoH::ENDPOINT_BACKUP, $resolver->getClient()->getEndpoint()); + $this->assertSame(Google::ENDPOINT_BACKUP, $resolver->getClient()->getEndpoint()); $response = $resolver->resolve(Message::query( new Question( @@ -113,17 +113,17 @@ public function testResolveWithBackupEndpoint(): void public function testGetName(): void { - $resolver = new GoogleDoH(); - $this->assertStringContainsString('Google DoH', $resolver->getName()); - $this->assertStringContainsString(GoogleDoH::ENDPOINT_PRIMARY, $resolver->getName()); + $resolver = new Google(); + $this->assertStringContainsString('Google HTTP', $resolver->getName()); + $this->assertStringContainsString(Google::ENDPOINT_PRIMARY, $resolver->getName()); - $resolverBackup = new GoogleDoH(useBackup: true); - $this->assertStringContainsString(GoogleDoH::ENDPOINT_BACKUP, $resolverBackup->getName()); + $resolverBackup = new Google(useBackup: true); + $this->assertStringContainsString(Google::ENDPOINT_BACKUP, $resolverBackup->getName()); } public function testEndpointConstants(): void { - $this->assertSame('https://dns.google/dns-query', GoogleDoH::ENDPOINT_PRIMARY); - $this->assertSame('https://dns.google/dns-query', GoogleDoH::ENDPOINT_BACKUP); + $this->assertSame('https://dns.google/dns-query', Google::ENDPOINT_PRIMARY); + $this->assertSame('https://dns.google/dns-query', Google::ENDPOINT_BACKUP); } } diff --git a/tests/e2e/DNS/Resolver/DoHTest.php b/tests/e2e/DNS/Resolver/HttpTest.php similarity index 75% rename from tests/e2e/DNS/Resolver/DoHTest.php rename to tests/e2e/DNS/Resolver/HttpTest.php index b284e9b..5eb6e91 100644 --- a/tests/e2e/DNS/Resolver/DoHTest.php +++ b/tests/e2e/DNS/Resolver/HttpTest.php @@ -3,18 +3,17 @@ namespace Tests\E2E\Utopia\DNS\Resolver; use PHPUnit\Framework\TestCase; -use Utopia\DNS\DoHClient; +use Utopia\DNS\Http\Client; use Utopia\DNS\Message; use Utopia\DNS\Message\Question; use Utopia\DNS\Message\Record; -use Utopia\DNS\Resolver\DoH; +use Utopia\DNS\Resolver\Http; -final class DoHTest extends TestCase +final class HttpTest extends TestCase { public function testResolveWithCustomEndpoint(): void { - // Using Cloudflare's endpoint as a custom endpoint - $resolver = new DoH('https://cloudflare-dns.com/dns-query'); + $resolver = new Http('https://cloudflare-dns.com/dns-query'); $response = $resolver->resolve(Message::query( new Question( @@ -35,7 +34,7 @@ public function testResolveWithCustomEndpoint(): void public function testResolveWithGetMethod(): void { - $resolver = new DoH('https://cloudflare-dns.com/dns-query', 5, DoHClient::METHOD_GET); + $resolver = new Http('https://cloudflare-dns.com/dns-query', 5, 2, Client::METHOD_GET); $response = $resolver->resolve(Message::query( new Question( @@ -50,7 +49,7 @@ public function testResolveWithGetMethod(): void public function testResolveWithPostMethod(): void { - $resolver = new DoH('https://cloudflare-dns.com/dns-query', 5, DoHClient::METHOD_POST); + $resolver = new Http('https://cloudflare-dns.com/dns-query', 5, 2, Client::METHOD_POST); $response = $resolver->resolve(Message::query( new Question( @@ -65,23 +64,23 @@ public function testResolveWithPostMethod(): void public function testGetName(): void { - $resolver = new DoH('https://custom-dns.example.com/dns-query'); - $this->assertSame('DoH (https://custom-dns.example.com/dns-query)', $resolver->getName()); + $resolver = new Http('https://custom-dns.example.com/dns-query'); + $this->assertSame('HTTP (https://custom-dns.example.com/dns-query)', $resolver->getName()); } public function testGetClient(): void { - $resolver = new DoH('https://cloudflare-dns.com/dns-query', 10, DoHClient::METHOD_GET); + $resolver = new Http('https://cloudflare-dns.com/dns-query', 10, 2, Client::METHOD_GET); $client = $resolver->getClient(); - $this->assertInstanceOf(DoHClient::class, $client); + $this->assertInstanceOf(Client::class, $client); $this->assertSame('https://cloudflare-dns.com/dns-query', $client->getEndpoint()); - $this->assertSame(DoHClient::METHOD_GET, $client->getMethod()); + $this->assertSame(Client::METHOD_GET, $client->getMethod()); } public function testResolveTXTRecord(): void { - $resolver = new DoH('https://cloudflare-dns.com/dns-query'); + $resolver = new Http('https://cloudflare-dns.com/dns-query'); $response = $resolver->resolve(Message::query( new Question( @@ -104,7 +103,7 @@ public function testResolveTXTRecord(): void public function testResolveNSRecord(): void { - $resolver = new DoH('https://cloudflare-dns.com/dns-query'); + $resolver = new Http('https://cloudflare-dns.com/dns-query'); $response = $resolver->resolve(Message::query( new Question( diff --git a/tests/unit/DNS/DoHClientTest.php b/tests/unit/DNS/Http/ClientTest.php similarity index 55% rename from tests/unit/DNS/DoHClientTest.php rename to tests/unit/DNS/Http/ClientTest.php index 940e2bf..b57db40 100644 --- a/tests/unit/DNS/DoHClientTest.php +++ b/tests/unit/DNS/Http/ClientTest.php @@ -1,18 +1,18 @@ expectException(\Exception::class); $this->expectExceptionMessage('Invalid DoH endpoint URL.'); - new DoHClient('not-a-valid-url'); + new Client('not-a-valid-url'); } public function testConstructorValidatesMethod(): void @@ -20,36 +20,36 @@ public function testConstructorValidatesMethod(): void $this->expectException(\Exception::class); $this->expectExceptionMessage('Invalid HTTP method. Use GET or POST.'); - new DoHClient('https://cloudflare-dns.com/dns-query', 5, 'PUT'); + new Client('https://cloudflare-dns.com/dns-query', 5, 2, 'PUT'); } public function testConstructorAcceptsValidEndpoint(): void { - $client = new DoHClient('https://cloudflare-dns.com/dns-query'); + $client = new Client('https://cloudflare-dns.com/dns-query'); $this->assertSame('https://cloudflare-dns.com/dns-query', $client->getEndpoint()); - $this->assertSame(DoHClient::METHOD_POST, $client->getMethod()); + $this->assertSame(Client::METHOD_POST, $client->getMethod()); } public function testConstructorAcceptsGetMethod(): void { - $client = new DoHClient('https://dns.google/dns-query', 5, DoHClient::METHOD_GET); + $client = new Client('https://dns.google/dns-query', 5, 2, Client::METHOD_GET); $this->assertSame('https://dns.google/dns-query', $client->getEndpoint()); - $this->assertSame(DoHClient::METHOD_GET, $client->getMethod()); + $this->assertSame(Client::METHOD_GET, $client->getMethod()); } public function testConstructorAcceptsPostMethod(): void { - $client = new DoHClient('https://dns.google/dns-query', 5, DoHClient::METHOD_POST); + $client = new Client('https://dns.google/dns-query', 5, 2, Client::METHOD_POST); $this->assertSame('https://dns.google/dns-query', $client->getEndpoint()); - $this->assertSame(DoHClient::METHOD_POST, $client->getMethod()); + $this->assertSame(Client::METHOD_POST, $client->getMethod()); } public function testMethodConstants(): void { - $this->assertSame('GET', DoHClient::METHOD_GET); - $this->assertSame('POST', DoHClient::METHOD_POST); + $this->assertSame('GET', Client::METHOD_GET); + $this->assertSame('POST', Client::METHOD_POST); } } From 8265023bbfdea3e374810701a20046ef4e3681b4 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 6 May 2026 04:25:21 +0000 Subject: [PATCH 4/6] fix: guard curl_init failures and fix Google DoH backup endpoint - Throw Exception when curl_init() returns false in queryPost/queryGet (prevents fatal TypeError on rare cURL init failures) - Change Google ENDPOINT_BACKUP to https://8.8.8.8/dns-query so useBackup actually fails over to a distinct IP-addressed endpoint that survives a DNS resolution failure for dns.google Co-Authored-By: Claude Opus 4.7 (1M context) --- src/DNS/Http/Client.php | 8 ++++++++ src/DNS/Resolver/Http/Google.php | 7 ++----- tests/e2e/DNS/Resolver/Http/GoogleTest.php | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/DNS/Http/Client.php b/src/DNS/Http/Client.php index 859079c..a7f22b9 100644 --- a/src/DNS/Http/Client.php +++ b/src/DNS/Http/Client.php @@ -69,6 +69,10 @@ protected function queryPost(Message $message): Message $ch = curl_init(); + if ($ch === false) { + throw new Exception('Failed to initialize cURL handle.'); + } + curl_setopt_array($ch, [ CURLOPT_URL => $this->endpoint, CURLOPT_POST => true, @@ -124,6 +128,10 @@ protected function queryGet(Message $message): Message $ch = curl_init(); + if ($ch === false) { + throw new Exception('Failed to initialize cURL handle.'); + } + curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, diff --git a/src/DNS/Resolver/Http/Google.php b/src/DNS/Resolver/Http/Google.php index 886c067..5aff868 100644 --- a/src/DNS/Resolver/Http/Google.php +++ b/src/DNS/Resolver/Http/Google.php @@ -10,17 +10,14 @@ * * Uses Google's public DoH endpoints: * - Primary: https://dns.google/dns-query - * - Backup: https://dns.google/dns-query (same endpoint, Google handles load balancing) - * - * Note: Google's DNS infrastructure provides built-in redundancy, - * so both endpoints resolve to the same highly-available service. + * - Backup: https://8.8.8.8/dns-query (IP-addressed; survives DNS resolution failure for dns.google) * * @see https://developers.google.com/speed/public-dns/docs/doh */ class Google extends Http { public const ENDPOINT_PRIMARY = 'https://dns.google/dns-query'; - public const ENDPOINT_BACKUP = 'https://dns.google/dns-query'; + public const ENDPOINT_BACKUP = 'https://8.8.8.8/dns-query'; /** * Create a new Google HTTP (DoH) resolver diff --git a/tests/e2e/DNS/Resolver/Http/GoogleTest.php b/tests/e2e/DNS/Resolver/Http/GoogleTest.php index 739598f..36f4a23 100644 --- a/tests/e2e/DNS/Resolver/Http/GoogleTest.php +++ b/tests/e2e/DNS/Resolver/Http/GoogleTest.php @@ -124,6 +124,6 @@ public function testGetName(): void public function testEndpointConstants(): void { $this->assertSame('https://dns.google/dns-query', Google::ENDPOINT_PRIMARY); - $this->assertSame('https://dns.google/dns-query', Google::ENDPOINT_BACKUP); + $this->assertSame('https://8.8.8.8/dns-query', Google::ENDPOINT_BACKUP); } } From 0d3ffdd5554b8a49f7a633483f889139cae781bc Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 6 May 2026 04:31:34 +0000 Subject: [PATCH 5/6] fix: restrict DoH cURL redirects to HTTPS only Add CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS in both queryPost and queryGet so a malicious or misbehaving DoH server cannot redirect a query to plaintext HTTP, which would defeat the security guarantee of DNS-over-HTTPS. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/DNS/Http/Client.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/DNS/Http/Client.php b/src/DNS/Http/Client.php index a7f22b9..4a1c826 100644 --- a/src/DNS/Http/Client.php +++ b/src/DNS/Http/Client.php @@ -86,6 +86,7 @@ protected function queryPost(Message $message): Message ], CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, ]); $response = curl_exec($ch); @@ -142,6 +143,7 @@ protected function queryGet(Message $message): Message ], CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, ]); $response = curl_exec($ch); From 37252e06bbf14b5d3f094d1c0fee045e7716730c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 6 May 2026 04:36:19 +0000 Subject: [PATCH 6/6] fix: reject non-HTTPS DoH endpoints FILTER_VALIDATE_URL accepts any scheme (http, ftp, ...). For DoH the endpoint must be HTTPS, otherwise the encryption guarantee is silently lost. Add an explicit https scheme check and a unit test for the http rejection path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/DNS/Http/Client.php | 4 ++-- tests/unit/DNS/Http/ClientTest.php | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/DNS/Http/Client.php b/src/DNS/Http/Client.php index 4a1c826..f8aa9f1 100644 --- a/src/DNS/Http/Client.php +++ b/src/DNS/Http/Client.php @@ -30,8 +30,8 @@ public function __construct( protected int $connectTimeout = 2, protected string $method = self::METHOD_POST ) { - if (!filter_var($endpoint, FILTER_VALIDATE_URL)) { - throw new Exception('Invalid DoH endpoint URL.'); + if (!filter_var($endpoint, FILTER_VALIDATE_URL) || parse_url($endpoint, PHP_URL_SCHEME) !== 'https') { + throw new Exception('Invalid DoH endpoint URL. Must be a valid HTTPS URL.'); } if (!in_array($method, [self::METHOD_GET, self::METHOD_POST])) { diff --git a/tests/unit/DNS/Http/ClientTest.php b/tests/unit/DNS/Http/ClientTest.php index b57db40..3419844 100644 --- a/tests/unit/DNS/Http/ClientTest.php +++ b/tests/unit/DNS/Http/ClientTest.php @@ -10,11 +10,19 @@ final class ClientTest extends TestCase public function testConstructorValidatesEndpoint(): void { $this->expectException(\Exception::class); - $this->expectExceptionMessage('Invalid DoH endpoint URL.'); + $this->expectExceptionMessage('Invalid DoH endpoint URL. Must be a valid HTTPS URL.'); new Client('not-a-valid-url'); } + public function testConstructorRejectsHttpScheme(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid DoH endpoint URL. Must be a valid HTTPS URL.'); + + new Client('http://example.com/dns-query'); + } + public function testConstructorValidatesMethod(): void { $this->expectException(\Exception::class);