From dade82706a603672f96f47281288e10a22f72f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 5 Aug 2025 13:38:56 +0200 Subject: [PATCH 1/6] Fix caa records inheritance --- src/Appwrite/Network/Validator/DNS.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Network/Validator/DNS.php b/src/Appwrite/Network/Validator/DNS.php index 7549d18f544..479a7736097 100644 --- a/src/Appwrite/Network/Validator/DNS.php +++ b/src/Appwrite/Network/Validator/DNS.php @@ -65,9 +65,18 @@ public function isValid($value): bool } if (empty($query)) { - // No CAA records means anyone can issue certificate + // CAA records inherit from parent (custom CAA behaviour) if ($this->type === self::RECORD_CAA) { - return true; + if (\substr_count($value, ".") === 1) { + return true; // No CAA on apex domain means anyone can issue certificate + } + + // Recursive validation by parent domain + $parts = \explode('.', $value); + \array_shift($parts); + $parentDomain = \implode('.', $parts); + $validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''), DNS::RECORD_CAA); + return $validator->isValid($parentDomain); } return false; From 2d4e99cb1ac79a3610ee5fbe4e32074efe6285e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 5 Aug 2025 13:44:06 +0200 Subject: [PATCH 2/6] Revert revert of CAA validation --- .env | 2 + app/config/variables.php | 18 ++++++ app/controllers/api/console.php | 2 + app/controllers/api/proxy.php | 13 ++++ app/views/install/compose.phtml | 8 +++ composer.json | 1 + composer.lock | 62 ++++++++++++++++++- docker-compose.yml | 8 +++ src/Appwrite/Network/Validator/DNS.php | 51 ++++++++------- .../Platform/Workers/Certificates.php | 13 ++++ .../Response/Model/ConsoleVariables.php | 42 +++++++------ .../Console/ConsoleConsoleClientTest.php | 3 +- tests/unit/Network/Validators/DNSTest.php | 27 ++++++++ 13 files changed, 205 insertions(+), 45 deletions(-) diff --git a/.env b/.env index 76af83a9466..4d7c038a6b9 100644 --- a/.env +++ b/.env @@ -21,6 +21,7 @@ _APP_OPTIONS_ROUTER_PROTECTION=disabled _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled _APP_OPENSSL_KEY_V1=your-secret-key +_APP_DNS=8.8.8.8 _APP_DOMAIN=traefik _APP_CONSOLE_DOMAIN=localhost _APP_DOMAIN_FUNCTIONS=functions.localhost @@ -28,6 +29,7 @@ _APP_DOMAIN_SITES=sites.localhost _APP_DOMAIN_TARGET_CNAME=test.localhost _APP_DOMAIN_TARGET_A=127.0.0.1 _APP_DOMAIN_TARGET_AAAA=::1 +_APP_DOMAIN_TARGET_CAA=digicert.com _APP_RULES_FORMAT=md5 _APP_REDIS_HOST=redis _APP_REDIS_PORT=6379 diff --git a/app/config/variables.php b/app/config/variables.php index 71ed13a4835..8fd00557b3f 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -151,6 +151,24 @@ 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_DOMAIN_TARGET_CAA', + 'description' => 'A CAA record domain that can be used to validate custom domains. Value should be domain\'s hostname.', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DNS', + 'description' => 'DNS server to use for domain validation. Default: 8.8.8.8', + 'introduction' => '', + 'default' => '8.8.8.8', + 'required' => false, + 'question' => '', + 'filter' => '' + ], [ 'name' => '_APP_CONSOLE_WHITELIST_ROOT', 'description' => 'This option allows you to disable the creation of new users on the Appwrite console. When enabled only 1 user will be able to use the registration form. New users can be added by inviting them to your project. By default this option is enabled.', diff --git a/app/controllers/api/console.php b/app/controllers/api/console.php index 558dc0e4ef0..b0619df3b36 100644 --- a/app/controllers/api/console.php +++ b/app/controllers/api/console.php @@ -71,6 +71,8 @@ '_APP_DOMAIN_TARGET_CNAME' => System::getEnv('_APP_DOMAIN_TARGET_CNAME'), '_APP_DOMAIN_TARGET_AAAA' => System::getEnv('_APP_DOMAIN_TARGET_AAAA'), '_APP_DOMAIN_TARGET_A' => System::getEnv('_APP_DOMAIN_TARGET_A'), + // Combine CAA domain with most common flags and tag (no parameters) + '_APP_DOMAIN_TARGET_CAA' => '0 issue "' . System::getEnv('_APP_DOMAIN_TARGET_CAA') . '"', '_APP_STORAGE_LIMIT' => +System::getEnv('_APP_STORAGE_LIMIT'), '_APP_COMPUTE_SIZE_LIMIT' => +System::getEnv('_APP_COMPUTE_SIZE_LIMIT'), '_APP_USAGE_STATS' => System::getEnv('_APP_USAGE_STATS'), diff --git a/app/controllers/api/proxy.php b/app/controllers/api/proxy.php index 417ea602bac..6b76b0c34cd 100644 --- a/app/controllers/api/proxy.php +++ b/app/controllers/api/proxy.php @@ -286,6 +286,19 @@ throw new Exception(Exception::RULE_VERIFICATION_FAILED); } + // Ensure CAA won't block certificate issuance + if (!empty(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''))) { + $validationStart = \microtime(true); + $validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''), DNS::RECORD_CAA); + if (!$validator->isValid($domain->get())) { + $log->addExtra('dnsTimingCaa', \strval(\microtime(true) - $validationStart)); + $log->addTag('dnsDomain', $domain->get()); + $error = $validator->getDescription(); + $log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error)); + throw new Exception(Exception::RULE_VERIFICATION_FAILED, 'Domain verification failed because CAA records do not allow certainly.com to issue certificates.'); + } + } + $dbForPlatform->updateDocument('rules', $rule->getId(), $rule->setAttribute('status', 'verifying')); // Issue a TLS certificate when domain is verified diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 89facfe0f1b..34940e7c6e1 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -95,6 +95,8 @@ $image = $this->getParam('image', ''); - _APP_DOMAIN_TARGET_CNAME - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A + - _APP_DOMAIN_TARGET_CAA + - _APP_DOMAINS_DNS - _APP_DOMAIN_FUNCTIONS - _APP_REDIS_HOST - _APP_REDIS_PORT @@ -472,6 +474,8 @@ $image = $this->getParam('image', ''); - _APP_DOMAIN_TARGET_CNAME - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A + - _APP_DOMAIN_TARGET_CAA + - _APP_DOMAINS_DNS - _APP_DOMAIN_FUNCTIONS - _APP_EMAIL_CERTIFICATES - _APP_REDIS_HOST @@ -629,6 +633,8 @@ $image = $this->getParam('image', ''); - _APP_DOMAIN_TARGET_CNAME - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A + - _APP_DOMAIN_TARGET_CAA + - _APP_DOMAINS_DNS - _APP_EMAIL_SECURITY - _APP_REDIS_HOST - _APP_REDIS_PORT @@ -660,6 +666,8 @@ $image = $this->getParam('image', ''); - _APP_DOMAIN_TARGET_CNAME - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A + - _APP_DOMAIN_TARGET_CAA + - _APP_DOMAINS_DNS - _APP_DOMAIN_FUNCTIONS - _APP_OPENSSL_KEY_V1 - _APP_REDIS_HOST diff --git a/composer.json b/composer.json index 73cdcc3d86c..8ba8e49f4ae 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,7 @@ "utopia-php/database": "0.71.*", "utopia-php/detector": "0.1.*", "utopia-php/domains": "0.8.*", + "utopia-php/dns": "0.3.*", "utopia-php/dsn": "0.2.1", "utopia-php/framework": "0.33.*", "utopia-php/fetch": "0.4.*", diff --git a/composer.lock b/composer.lock index e3b3d2208f9..32164f56d5f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7b2ef6192403daf5c492219822ce0aa1", + "content-hash": "761a7e17b49381e68038c92873888125", "packages": [ { "name": "adhocore/jwt", @@ -3641,6 +3641,62 @@ }, "time": "2025-05-19T11:01:28+00:00" }, + { + "name": "utopia-php/dns", + "version": "0.3.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/dns.git", + "reference": "8fd4161bc3a8021a670c1101b40f6b09a97f1a54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/dns/zipball/8fd4161bc3a8021a670c1101b40f6b09a97f1a54", + "reference": "8fd4161bc3a8021a670c1101b40f6b09a97f1a54", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "utopia-php/cli": "0.15.*", + "utopia-php/telemetry": "^0.1.1" + }, + "require-dev": { + "laravel/pint": "1.2.*", + "phpstan/phpstan": "1.8.*", + "phpunit/phpunit": "^9.3", + "rregeer/phpunit-coverage-check": "^0.3.1", + "swoole/ide-helper": "4.6.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\DNS\\": "src/DNS" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eldad Fux", + "email": "eldad@appwrite.io" + } + ], + "description": "Lite & fast micro PHP DNS server abstraction that is **easy to use**.", + "keywords": [ + "dns", + "framework", + "php", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/dns/issues", + "source": "https://github.com/utopia-php/dns/tree/0.3.0" + }, + "time": "2025-08-04T11:05:53+00:00" + }, { "name": "utopia-php/domains", "version": "0.8.0", @@ -8308,7 +8364,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -8332,5 +8388,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 58b78fcd8e1..0e299c8a2cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -120,6 +120,8 @@ services: - _APP_DOMAIN_TARGET_CNAME - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A + - _APP_DOMAIN_TARGET_CAA + - _APP_DNS - _APP_DOMAIN_FUNCTIONS - _APP_REDIS_HOST - _APP_REDIS_PORT @@ -535,6 +537,8 @@ services: - _APP_DOMAIN_TARGET_CNAME - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A + - _APP_DOMAIN_TARGET_CAA + - _APP_DNS - _APP_DOMAIN_FUNCTIONS - _APP_EMAIL_CERTIFICATES - _APP_REDIS_HOST @@ -704,6 +708,8 @@ services: - _APP_DOMAIN_TARGET_CNAME - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A + - _APP_DOMAIN_TARGET_CAA + - _APP_DNS - _APP_EMAIL_SECURITY - _APP_REDIS_HOST - _APP_REDIS_PORT @@ -738,6 +744,8 @@ services: - _APP_DOMAIN_TARGET_CNAME - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A + - _APP_DOMAIN_TARGET_CAA + - _APP_DNS - _APP_DOMAIN_FUNCTIONS - _APP_OPENSSL_KEY_V1 - _APP_REDIS_HOST diff --git a/src/Appwrite/Network/Validator/DNS.php b/src/Appwrite/Network/Validator/DNS.php index 2df947816af..479a7736097 100644 --- a/src/Appwrite/Network/Validator/DNS.php +++ b/src/Appwrite/Network/Validator/DNS.php @@ -2,13 +2,16 @@ namespace Appwrite\Network\Validator; +use Utopia\DNS\Client; +use Utopia\System\System; use Utopia\Validator; class DNS extends Validator { - public const RECORD_A = 'a'; - public const RECORD_AAAA = 'aaaa'; - public const RECORD_CNAME = 'cname'; + public const RECORD_A = 'A'; + public const RECORD_AAAA = 'AAAA'; + public const RECORD_CNAME = 'CNAME'; + public const RECORD_CAA = 'CAA'; // You can provide domain only (as $target) for CAA validation /** * @var mixed @@ -42,33 +45,22 @@ public function getLogs(): mixed * Check if DNS record value matches specific value * * @param mixed $domain - * * @return bool */ public function isValid($value): bool { - $typeNative = match ($this->type) { - self::RECORD_A => DNS_A, - self::RECORD_AAAA => DNS_AAAA, - self::RECORD_CNAME => DNS_CNAME, - default => throw new \Exception('Record type not supported.') - }; - - $dnsKey = match ($this->type) { - self::RECORD_A => 'ip', - self::RECORD_AAAA => 'ipv6', - self::RECORD_CNAME => 'target', - default => throw new \Exception('Record type not supported.') - }; - if (!is_string($value)) { return false; } + $dnsServer = System::getEnv('_APP_DNS', '8.8.8.8'); + $dns = new Client($dnsServer); + try { - $records = \dns_get_record($value, $typeNative); - $this->logs = $records; - } catch (\Throwable $th) { + $query = $dns->query($value, $this->type); + $this->logs = $query; + } catch (\Exception $e) { + $this->logs = ['error' => $e->getMessage()]; return false; } @@ -90,8 +82,21 @@ public function isValid($value): bool return false; } - foreach ($records as $record) { - if (isset($record[$dnsKey]) && $record[$dnsKey] === $this->target) { + foreach ($query as $record) { + // CAA validation only needs to ensure domain + if ($this->type === self::RECORD_CAA) { + // Extract domain; comments showcase extraction steps in most complex scenario + $rdata = $record->getRdata(); // 255 issuewild "certainly.com;validationmethods=tls-alpn-01;retrytimeout=3600" + $rdata = \explode(' ', $rdata, 3)[2] ?? ''; // "certainly.com;validationmethods=tls-alpn-01;retrytimeout=3600" + $rdata = \trim($rdata, '"'); // certainly.com;validationmethods=tls-alpn-01;retrytimeout=3600 + $rdata = \explode(';', $rdata, 2)[0] ?? ''; // certainly.com + + if ($rdata === $this->target) { + return true; + } + } + + if ($record->getRdata() === $this->target) { return true; } } diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 207f95ff7d3..d14bf0428d3 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -337,6 +337,19 @@ private function validateDomain(Domain $domain, bool $isMainDomain, Log $log): v throw new Exception('Failed to verify domain DNS records.'); } + + // Ensure CAA won't block certificate issuance + if (!empty(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''))) { + $validationStart = \microtime(true); + $validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''), DNS::RECORD_CAA); + if (!$validator->isValid($domain->get())) { + $log->addExtra('dnsTimingCaa', \strval(\microtime(true) - $validationStart)); + $log->addTag('dnsDomain', $domain->get()); + $error = $validator->getDescription(); + $log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error)); + throw new Exception('Failed to verify domain DNS records. CAA records do not allow certificates from certainly.com to issue certificates.'); + } + } } else { // Main domain validation // TODO: Would be awesome to check A/AAAA record here. Maybe dry run? diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php b/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php index 97dae2efcdb..b81502f0a13 100644 --- a/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php +++ b/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php @@ -10,24 +10,30 @@ class ConsoleVariables extends Model public function __construct() { $this - ->addRule('_APP_DOMAIN_TARGET_CNAME', [ - 'type' => self::TYPE_STRING, - 'description' => 'CNAME target for your Appwrite custom domains.', - 'default' => '', - 'example' => 'appwrite.io', - ]) - ->addRule('_APP_DOMAIN_TARGET_A', [ - 'type' => self::TYPE_STRING, - 'description' => 'A target for your Appwrite custom domains.', - 'default' => '', - 'example' => '127.0.0.1', - ]) - ->addRule('_APP_DOMAIN_TARGET_AAAA', [ - 'type' => self::TYPE_STRING, - 'description' => 'AAAA target for your Appwrite custom domains.', - 'default' => '', - 'example' => '::1', - ]) + ->addRule('_APP_DOMAIN_TARGET_CNAME', [ + 'type' => self::TYPE_STRING, + 'description' => 'CNAME target for your Appwrite custom domains.', + 'default' => '', + 'example' => 'appwrite.io', + ]) + ->addRule('_APP_DOMAIN_TARGET_A', [ + 'type' => self::TYPE_STRING, + 'description' => 'A target for your Appwrite custom domains.', + 'default' => '', + 'example' => '127.0.0.1', + ]) + ->addRule('_APP_DOMAIN_TARGET_AAAA', [ + 'type' => self::TYPE_STRING, + 'description' => 'AAAA target for your Appwrite custom domains.', + 'default' => '', + 'example' => '::1', + ]) + ->addRule('_APP_DOMAIN_TARGET_CAA', [ + 'type' => self::TYPE_STRING, + 'description' => 'CAA target for your Appwrite custom domains.', + 'default' => '', + 'example' => 'digicert.com', + ]) ->addRule('_APP_STORAGE_LIMIT', [ 'type' => self::TYPE_INTEGER, 'description' => 'Maximum file size allowed for file upload in bytes.', diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index 6059cb2000c..340cabc8c0e 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -24,10 +24,11 @@ public function testGetVariables(): void ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertCount(13, $response['body']); + $this->assertCount(14, $response['body']); $this->assertIsString($response['body']['_APP_DOMAIN_TARGET_CNAME']); $this->assertIsString($response['body']['_APP_DOMAIN_TARGET_A']); $this->assertIsString($response['body']['_APP_DOMAIN_TARGET_AAAA']); + $this->assertIsString($response['body']['_APP_DOMAIN_TARGET_CAA']); $this->assertIsInt($response['body']['_APP_STORAGE_LIMIT']); $this->assertIsInt($response['body']['_APP_COMPUTE_SIZE_LIMIT']); $this->assertIsBool($response['body']['_APP_DOMAIN_ENABLED']); diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php index 5e8652381a0..6609a0838ac 100644 --- a/tests/unit/Network/Validators/DNSTest.php +++ b/tests/unit/Network/Validators/DNSTest.php @@ -47,4 +47,31 @@ public function testAAAA(): void $this->assertEquals($validator->isValid('aaaa-unit-test.appwrite.org'), true); $this->assertEquals($validator->isValid('test1.appwrite.org'), false); } + + public function testCAA(): void + { + $validator = new DNS('digicert.com', DNS::RECORD_CAA); + $this->assertEquals($validator->isValid('github.com'), true); + $this->assertEquals($validator->isValid('test1.appwrite.org'), true); + + $validator = new DNS('0 issue "digicert.com"', DNS::RECORD_CAA); + $this->assertEquals($validator->isValid('github.com'), true); + + $validator = new DNS('0 issuewild "digicert.com"', DNS::RECORD_CAA); + $this->assertEquals($validator->isValid('github.com'), true); + + $validator = new DNS('128 issue "digicert.com"', DNS::RECORD_CAA); + $this->assertEquals($validator->isValid('github.com'), false); + + $validator = new DNS('letsencrypt.org', DNS::RECORD_CAA); + $this->assertEquals($validator->isValid('github.com'), false); + + // Valid becasue no CAA record configured + $validator = new DNS('anything.com', DNS::RECORD_CAA); + $this->assertEquals($validator->isValid('cloud.appwrite.io'), true); + + // Valid becasue no CAA record configured + $validator = new DNS('something.org', DNS::RECORD_CAA); + $this->assertEquals($validator->isValid('cloud.appwrite.io'), true); + } } From 61ec98bc5e70a23d26b2ed13c9f63b5276d67e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 6 Aug 2025 11:06:18 +0200 Subject: [PATCH 3/6] Improve tests --- src/Appwrite/Network/Validator/DNS.php | 17 +++-- tests/unit/Network/Validators/DNSTest.php | 81 ++++++++++++++++------- 2 files changed, 69 insertions(+), 29 deletions(-) diff --git a/src/Appwrite/Network/Validator/DNS.php b/src/Appwrite/Network/Validator/DNS.php index 479a7736097..52ec547592d 100644 --- a/src/Appwrite/Network/Validator/DNS.php +++ b/src/Appwrite/Network/Validator/DNS.php @@ -17,12 +17,22 @@ class DNS extends Validator * @var mixed */ protected mixed $logs; + + /** + * @var string + */ + protected string $dnsServer; /** * @param string $target */ - public function __construct(protected string $target, protected string $type = self::RECORD_CNAME) + public function __construct(protected string $target, protected string $type = self::RECORD_CNAME, string $dnsServer = '') { + if(empty($dnsServer)) { + $dnsServer = System::getEnv('_APP_DNS', '8.8.8.8'); + } + + $this->dnsServer = $dnsServer; } /** @@ -53,8 +63,7 @@ public function isValid($value): bool return false; } - $dnsServer = System::getEnv('_APP_DNS', '8.8.8.8'); - $dns = new Client($dnsServer); + $dns = new Client($this->dnsServer); try { $query = $dns->query($value, $this->type); @@ -75,7 +84,7 @@ public function isValid($value): bool $parts = \explode('.', $value); \array_shift($parts); $parentDomain = \implode('.', $parts); - $validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''), DNS::RECORD_CAA); + $validator = new DNS($this->target, DNS::RECORD_CAA, $this->dnsServer); return $validator->isValid($parentDomain); } diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php index 6609a0838ac..66e45138648 100644 --- a/tests/unit/Network/Validators/DNSTest.php +++ b/tests/unit/Network/Validators/DNSTest.php @@ -3,8 +3,18 @@ namespace Tests\Unit\Network\Validators; use Appwrite\Network\Validator\DNS; +use Appwrite\Tests\Retry; use PHPUnit\Framework\TestCase; +/* +DNS Setup (on Appwrite Labs digital ocean team, network tab): + +certainly.caa.appwrite.org: CAA 0 issue "certainly.com" +certainly-full.caa.appwrite.org: CAA 128 issuewild "certainly.com;account=123456;validationmethods=dns-01" +letsencrypt.certainly.caa.appwrite.org: CAA 0 issue "letsencrypt.org" + +*/ + class DNSTest extends TestCase { public function setUp(): void @@ -47,31 +57,52 @@ public function testAAAA(): void $this->assertEquals($validator->isValid('aaaa-unit-test.appwrite.org'), true); $this->assertEquals($validator->isValid('test1.appwrite.org'), false); } - + + #[Retry(count: 5)] public function testCAA(): void - { - $validator = new DNS('digicert.com', DNS::RECORD_CAA); - $this->assertEquals($validator->isValid('github.com'), true); - $this->assertEquals($validator->isValid('test1.appwrite.org'), true); - - $validator = new DNS('0 issue "digicert.com"', DNS::RECORD_CAA); - $this->assertEquals($validator->isValid('github.com'), true); - - $validator = new DNS('0 issuewild "digicert.com"', DNS::RECORD_CAA); - $this->assertEquals($validator->isValid('github.com'), true); - - $validator = new DNS('128 issue "digicert.com"', DNS::RECORD_CAA); - $this->assertEquals($validator->isValid('github.com'), false); - - $validator = new DNS('letsencrypt.org', DNS::RECORD_CAA); - $this->assertEquals($validator->isValid('github.com'), false); - - // Valid becasue no CAA record configured - $validator = new DNS('anything.com', DNS::RECORD_CAA); - $this->assertEquals($validator->isValid('cloud.appwrite.io'), true); - - // Valid becasue no CAA record configured - $validator = new DNS('something.org', DNS::RECORD_CAA); - $this->assertEquals($validator->isValid('cloud.appwrite.io'), true); + { + $certainly = new DNS('certainly.com', DNS::RECORD_CAA, 'ns1.digitalocean.com'); + $letsencrypt = new DNS('letsencrypt.org', DNS::RECORD_CAA, 'ns1.digitalocean.com'); + + // No CAA record succeeds on main domain & subdomains for any issuer + $this->assertEquals($certainly->isValid('caa.appwrite.org'), true); + $this->assertEquals($certainly->isValid('sub.caa.appwrite.org'), true); + $this->assertEquals($certainly->isValid('sub.sub.caa.appwrite.org'), true); + + $this->assertEquals($letsencrypt->isValid('caa.appwrite.org'), true); + $this->assertEquals($letsencrypt->isValid('sub.caa.appwrite.org'), true); + $this->assertEquals($letsencrypt->isValid('sub.sub.caa.appwrite.org'), true); + + // Custom flags and tag is allowed, but only for Certainly + $this->assertEquals($certainly->isValid('certainly-full.caa.appwrite.org'), true); + $this->assertEquals($letsencrypt->isValid('certainly-full.caa.appwrite.org'), false); + + // Custom flags&tag are not allowed if validator includes specific flags&tag + $certainlyFull = new DNS('0 issue "certainly.com"', DNS::RECORD_CAA); + $this->assertEquals($certainlyFull->isValid('certainly-full.caa.appwrite.org'), false); + + // Custom flags&tag still allows if they match exactly + $certainlyFull = new DNS('128 issuewild "certainly.com;account=123456;validationmethods=dns-01"', DNS::RECORD_CAA); + $this->assertEquals($certainlyFull->isValid('certainly-full.caa.appwrite.org'), true); + + // Certainly CAA allows Certainly, but not LetsEncrypt; Same for subdomains + $this->assertEquals($certainly->isValid('certainly.caa.appwrite.org'), true); + $this->assertEquals($letsencrypt->isValid('certainly.caa.appwrite.org'), false); + + $this->assertEquals($certainly->isValid('sub.certainly.caa.appwrite.org'), true); + $this->assertEquals($letsencrypt->isValid('sub.certainly.caa.appwrite.org'), false); + + $this->assertEquals($certainly->isValid('sub.sub.certainly.caa.appwrite.org'), true); + $this->assertEquals($letsencrypt->isValid('sub.sub.certainly.caa.appwrite.org'), false); + + // LetsEncrypt CAA on subdomain with parent allowing Certainly. Only LetsEncrypt is allowed; Same for subdomains + $this->assertEquals($certainly->isValid('letsencrypt.certainly.caa.appwrite.org'), false); + $this->assertEquals($letsencrypt->isValid('letsencrypt.certainly.caa.appwrite.org'), true); + + $this->assertEquals($certainly->isValid('sub.letsencrypt.certainly.caa.appwrite.org'), false); + $this->assertEquals($letsencrypt->isValid('sub.letsencrypt.certainly.caa.appwrite.org'), true); + + $this->assertEquals($certainly->isValid('sub.sub.letsencrypt.certainly.caa.appwrite.org'), false); + $this->assertEquals($letsencrypt->isValid('sub.sub.letsencrypt.certainly.caa.appwrite.org'), true); } } From 10a7f8acfe0f07f14d5e2558e7923a1858d4e933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 6 Aug 2025 11:06:35 +0200 Subject: [PATCH 4/6] Formatting fix --- src/Appwrite/Network/Validator/DNS.php | 6 +++--- tests/unit/Network/Validators/DNSTest.php | 26 +++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Appwrite/Network/Validator/DNS.php b/src/Appwrite/Network/Validator/DNS.php index 52ec547592d..e03816f6767 100644 --- a/src/Appwrite/Network/Validator/DNS.php +++ b/src/Appwrite/Network/Validator/DNS.php @@ -17,7 +17,7 @@ class DNS extends Validator * @var mixed */ protected mixed $logs; - + /** * @var string */ @@ -28,10 +28,10 @@ class DNS extends Validator */ public function __construct(protected string $target, protected string $type = self::RECORD_CNAME, string $dnsServer = '') { - if(empty($dnsServer)) { + if (empty($dnsServer)) { $dnsServer = System::getEnv('_APP_DNS', '8.8.8.8'); } - + $this->dnsServer = $dnsServer; } diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php index 66e45138648..c3e819e7dcb 100644 --- a/tests/unit/Network/Validators/DNSTest.php +++ b/tests/unit/Network/Validators/DNSTest.php @@ -57,51 +57,51 @@ public function testAAAA(): void $this->assertEquals($validator->isValid('aaaa-unit-test.appwrite.org'), true); $this->assertEquals($validator->isValid('test1.appwrite.org'), false); } - + #[Retry(count: 5)] public function testCAA(): void - { + { $certainly = new DNS('certainly.com', DNS::RECORD_CAA, 'ns1.digitalocean.com'); $letsencrypt = new DNS('letsencrypt.org', DNS::RECORD_CAA, 'ns1.digitalocean.com'); - + // No CAA record succeeds on main domain & subdomains for any issuer $this->assertEquals($certainly->isValid('caa.appwrite.org'), true); $this->assertEquals($certainly->isValid('sub.caa.appwrite.org'), true); $this->assertEquals($certainly->isValid('sub.sub.caa.appwrite.org'), true); - + $this->assertEquals($letsencrypt->isValid('caa.appwrite.org'), true); $this->assertEquals($letsencrypt->isValid('sub.caa.appwrite.org'), true); $this->assertEquals($letsencrypt->isValid('sub.sub.caa.appwrite.org'), true); - + // Custom flags and tag is allowed, but only for Certainly $this->assertEquals($certainly->isValid('certainly-full.caa.appwrite.org'), true); $this->assertEquals($letsencrypt->isValid('certainly-full.caa.appwrite.org'), false); - + // Custom flags&tag are not allowed if validator includes specific flags&tag $certainlyFull = new DNS('0 issue "certainly.com"', DNS::RECORD_CAA); $this->assertEquals($certainlyFull->isValid('certainly-full.caa.appwrite.org'), false); - + // Custom flags&tag still allows if they match exactly $certainlyFull = new DNS('128 issuewild "certainly.com;account=123456;validationmethods=dns-01"', DNS::RECORD_CAA); $this->assertEquals($certainlyFull->isValid('certainly-full.caa.appwrite.org'), true); - + // Certainly CAA allows Certainly, but not LetsEncrypt; Same for subdomains $this->assertEquals($certainly->isValid('certainly.caa.appwrite.org'), true); $this->assertEquals($letsencrypt->isValid('certainly.caa.appwrite.org'), false); - + $this->assertEquals($certainly->isValid('sub.certainly.caa.appwrite.org'), true); $this->assertEquals($letsencrypt->isValid('sub.certainly.caa.appwrite.org'), false); - + $this->assertEquals($certainly->isValid('sub.sub.certainly.caa.appwrite.org'), true); $this->assertEquals($letsencrypt->isValid('sub.sub.certainly.caa.appwrite.org'), false); - + // LetsEncrypt CAA on subdomain with parent allowing Certainly. Only LetsEncrypt is allowed; Same for subdomains $this->assertEquals($certainly->isValid('letsencrypt.certainly.caa.appwrite.org'), false); $this->assertEquals($letsencrypt->isValid('letsencrypt.certainly.caa.appwrite.org'), true); - + $this->assertEquals($certainly->isValid('sub.letsencrypt.certainly.caa.appwrite.org'), false); $this->assertEquals($letsencrypt->isValid('sub.letsencrypt.certainly.caa.appwrite.org'), true); - + $this->assertEquals($certainly->isValid('sub.sub.letsencrypt.certainly.caa.appwrite.org'), false); $this->assertEquals($letsencrypt->isValid('sub.sub.letsencrypt.certainly.caa.appwrite.org'), true); } From 3541fcb2371d94f3e47732f61f52b1986b5db0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 6 Aug 2025 11:30:56 +0200 Subject: [PATCH 5/6] Fix manual QA edge case --- src/Appwrite/Network/Validator/DNS.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Network/Validator/DNS.php b/src/Appwrite/Network/Validator/DNS.php index e03816f6767..25e9bb1f3cf 100644 --- a/src/Appwrite/Network/Validator/DNS.php +++ b/src/Appwrite/Network/Validator/DNS.php @@ -66,7 +66,14 @@ public function isValid($value): bool $dns = new Client($this->dnsServer); try { - $query = $dns->query($value, $this->type); + $rawQuery = $dns->query($value, $this->type); + + // Some DNS servers return all records, not only type that's asked for + // Likely occurs when no records of specific type are found + $query = array_filter($rawQuery, function ($record) { + return $record->getTypeName() === $this->type; + }); + $this->logs = $query; } catch (\Exception $e) { $this->logs = ['error' => $e->getMessage()]; From aa837c7b43e55b857b738886a442a88d0ec9b465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 6 Aug 2025 11:43:07 +0200 Subject: [PATCH 6/6] PR review fixes --- app/controllers/api/proxy.php | 2 +- app/views/install/compose.phtml | 8 ++++---- src/Appwrite/Network/Validator/DNS.php | 4 +++- src/Appwrite/Platform/Workers/Certificates.php | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/proxy.php b/app/controllers/api/proxy.php index 6b76b0c34cd..4a644483352 100644 --- a/app/controllers/api/proxy.php +++ b/app/controllers/api/proxy.php @@ -295,7 +295,7 @@ $log->addTag('dnsDomain', $domain->get()); $error = $validator->getDescription(); $log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error)); - throw new Exception(Exception::RULE_VERIFICATION_FAILED, 'Domain verification failed because CAA records do not allow certainly.com to issue certificates.'); + throw new Exception(Exception::RULE_VERIFICATION_FAILED, 'Domain verification failed because CAA records do not allow Appwrite\'s certificate issuer.'); } } diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 34940e7c6e1..3c2f1f4c342 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -96,7 +96,7 @@ $image = $this->getParam('image', ''); - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A - _APP_DOMAIN_TARGET_CAA - - _APP_DOMAINS_DNS + - _APP_DNS - _APP_DOMAIN_FUNCTIONS - _APP_REDIS_HOST - _APP_REDIS_PORT @@ -475,7 +475,7 @@ $image = $this->getParam('image', ''); - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A - _APP_DOMAIN_TARGET_CAA - - _APP_DOMAINS_DNS + - _APP_DNS - _APP_DOMAIN_FUNCTIONS - _APP_EMAIL_CERTIFICATES - _APP_REDIS_HOST @@ -634,7 +634,7 @@ $image = $this->getParam('image', ''); - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A - _APP_DOMAIN_TARGET_CAA - - _APP_DOMAINS_DNS + - _APP_DNS - _APP_EMAIL_SECURITY - _APP_REDIS_HOST - _APP_REDIS_PORT @@ -667,7 +667,7 @@ $image = $this->getParam('image', ''); - _APP_DOMAIN_TARGET_AAAA - _APP_DOMAIN_TARGET_A - _APP_DOMAIN_TARGET_CAA - - _APP_DOMAINS_DNS + - _APP_DNS - _APP_DOMAIN_FUNCTIONS - _APP_OPENSSL_KEY_V1 - _APP_REDIS_HOST diff --git a/src/Appwrite/Network/Validator/DNS.php b/src/Appwrite/Network/Validator/DNS.php index 25e9bb1f3cf..f09bb42b023 100644 --- a/src/Appwrite/Network/Validator/DNS.php +++ b/src/Appwrite/Network/Validator/DNS.php @@ -3,6 +3,7 @@ namespace Appwrite\Network\Validator; use Utopia\DNS\Client; +use Utopia\Domains\Domain; use Utopia\System\System; use Utopia\Validator; @@ -83,7 +84,8 @@ public function isValid($value): bool if (empty($query)) { // CAA records inherit from parent (custom CAA behaviour) if ($this->type === self::RECORD_CAA) { - if (\substr_count($value, ".") === 1) { + $domain = new Domain($value); + if ($domain->get() === $domain->getApex()) { return true; // No CAA on apex domain means anyone can issue certificate } diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index d14bf0428d3..300383ef6dd 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -347,7 +347,7 @@ private function validateDomain(Domain $domain, bool $isMainDomain, Log $log): v $log->addTag('dnsDomain', $domain->get()); $error = $validator->getDescription(); $log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error)); - throw new Exception('Failed to verify domain DNS records. CAA records do not allow certificates from certainly.com to issue certificates.'); + throw new Exception('Failed to verify domain DNS records. CAA records do not allow Appwrite\'s certificate issuer.'); } } } else {