Revert revert of CAA validation

This commit is contained in:
Matej Bačo 2025-08-05 13:44:06 +02:00
parent 8fe999d6d7
commit 2d4e99cb1a
13 changed files with 205 additions and 45 deletions

2
.env
View file

@ -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

View file

@ -151,6 +151,24 @@ return [
'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.',

View file

@ -71,6 +71,8 @@ App::get('/v1/console/variables')
'_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'),

View file

@ -286,6 +286,19 @@ App::patch('/v1/proxy/rules/:ruleId/verification')
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

View file

@ -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

View file

@ -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.*",

62
composer.lock generated
View file

@ -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"
}

View file

@ -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

View file

@ -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 @@ class DNS extends Validator
* 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 @@ class DNS extends Validator
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;
}
}

View file

@ -337,6 +337,19 @@ class Certificates extends Action
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?

View file

@ -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.',

View file

@ -24,10 +24,11 @@ class ConsoleConsoleClientTest extends Scope
], $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']);

View file

@ -47,4 +47,31 @@ class DNSTest extends TestCase
$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);
}
}