Skip to content

Commit 7dd9ade

Browse files
authored
Merge pull request #10267 from appwrite/feat-check-CAA-in-DNS
Feat: CAA validator
2 parents 0109ca2 + aa837c7 commit 7dd9ade

13 files changed

Lines changed: 270 additions & 47 deletions

File tree

.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ _APP_OPTIONS_ROUTER_PROTECTION=disabled
2121
_APP_OPTIONS_FORCE_HTTPS=disabled
2222
_APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled
2323
_APP_OPENSSL_KEY_V1=your-secret-key
24+
_APP_DNS=8.8.8.8
2425
_APP_DOMAIN=traefik
2526
_APP_CONSOLE_DOMAIN=localhost
2627
_APP_DOMAIN_FUNCTIONS=functions.localhost
2728
_APP_DOMAIN_SITES=sites.localhost
2829
_APP_DOMAIN_TARGET_CNAME=test.localhost
2930
_APP_DOMAIN_TARGET_A=127.0.0.1
3031
_APP_DOMAIN_TARGET_AAAA=::1
32+
_APP_DOMAIN_TARGET_CAA=digicert.com
3133
_APP_RULES_FORMAT=md5
3234
_APP_REDIS_HOST=redis
3335
_APP_REDIS_PORT=6379

app/config/variables.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,24 @@
151151
'question' => '',
152152
'filter' => ''
153153
],
154+
[
155+
'name' => '_APP_DOMAIN_TARGET_CAA',
156+
'description' => 'A CAA record domain that can be used to validate custom domains. Value should be domain\'s hostname.',
157+
'introduction' => '',
158+
'default' => '',
159+
'required' => false,
160+
'question' => '',
161+
'filter' => ''
162+
],
163+
[
164+
'name' => '_APP_DNS',
165+
'description' => 'DNS server to use for domain validation. Default: 8.8.8.8',
166+
'introduction' => '',
167+
'default' => '8.8.8.8',
168+
'required' => false,
169+
'question' => '',
170+
'filter' => ''
171+
],
154172
[
155173
'name' => '_APP_CONSOLE_WHITELIST_ROOT',
156174
'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.',

app/controllers/api/console.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@
7171
'_APP_DOMAIN_TARGET_CNAME' => System::getEnv('_APP_DOMAIN_TARGET_CNAME'),
7272
'_APP_DOMAIN_TARGET_AAAA' => System::getEnv('_APP_DOMAIN_TARGET_AAAA'),
7373
'_APP_DOMAIN_TARGET_A' => System::getEnv('_APP_DOMAIN_TARGET_A'),
74+
// Combine CAA domain with most common flags and tag (no parameters)
75+
'_APP_DOMAIN_TARGET_CAA' => '0 issue "' . System::getEnv('_APP_DOMAIN_TARGET_CAA') . '"',
7476
'_APP_STORAGE_LIMIT' => +System::getEnv('_APP_STORAGE_LIMIT'),
7577
'_APP_COMPUTE_SIZE_LIMIT' => +System::getEnv('_APP_COMPUTE_SIZE_LIMIT'),
7678
'_APP_USAGE_STATS' => System::getEnv('_APP_USAGE_STATS'),

app/controllers/api/proxy.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,19 @@
286286
throw new Exception(Exception::RULE_VERIFICATION_FAILED);
287287
}
288288

289+
// Ensure CAA won't block certificate issuance
290+
if (!empty(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''))) {
291+
$validationStart = \microtime(true);
292+
$validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''), DNS::RECORD_CAA);
293+
if (!$validator->isValid($domain->get())) {
294+
$log->addExtra('dnsTimingCaa', \strval(\microtime(true) - $validationStart));
295+
$log->addTag('dnsDomain', $domain->get());
296+
$error = $validator->getDescription();
297+
$log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error));
298+
throw new Exception(Exception::RULE_VERIFICATION_FAILED, 'Domain verification failed because CAA records do not allow Appwrite\'s certificate issuer.');
299+
}
300+
}
301+
289302
$dbForPlatform->updateDocument('rules', $rule->getId(), $rule->setAttribute('status', 'verifying'));
290303

291304
// Issue a TLS certificate when domain is verified

app/views/install/compose.phtml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ $image = $this->getParam('image', '');
9595
- _APP_DOMAIN_TARGET_CNAME
9696
- _APP_DOMAIN_TARGET_AAAA
9797
- _APP_DOMAIN_TARGET_A
98+
- _APP_DOMAIN_TARGET_CAA
99+
- _APP_DNS
98100
- _APP_DOMAIN_FUNCTIONS
99101
- _APP_REDIS_HOST
100102
- _APP_REDIS_PORT
@@ -472,6 +474,8 @@ $image = $this->getParam('image', '');
472474
- _APP_DOMAIN_TARGET_CNAME
473475
- _APP_DOMAIN_TARGET_AAAA
474476
- _APP_DOMAIN_TARGET_A
477+
- _APP_DOMAIN_TARGET_CAA
478+
- _APP_DNS
475479
- _APP_DOMAIN_FUNCTIONS
476480
- _APP_EMAIL_CERTIFICATES
477481
- _APP_REDIS_HOST
@@ -629,6 +633,8 @@ $image = $this->getParam('image', '');
629633
- _APP_DOMAIN_TARGET_CNAME
630634
- _APP_DOMAIN_TARGET_AAAA
631635
- _APP_DOMAIN_TARGET_A
636+
- _APP_DOMAIN_TARGET_CAA
637+
- _APP_DNS
632638
- _APP_EMAIL_SECURITY
633639
- _APP_REDIS_HOST
634640
- _APP_REDIS_PORT
@@ -660,6 +666,8 @@ $image = $this->getParam('image', '');
660666
- _APP_DOMAIN_TARGET_CNAME
661667
- _APP_DOMAIN_TARGET_AAAA
662668
- _APP_DOMAIN_TARGET_A
669+
- _APP_DOMAIN_TARGET_CAA
670+
- _APP_DNS
663671
- _APP_DOMAIN_FUNCTIONS
664672
- _APP_OPENSSL_KEY_V1
665673
- _APP_REDIS_HOST

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"utopia-php/database": "0.71.*",
5656
"utopia-php/detector": "0.1.*",
5757
"utopia-php/domains": "0.8.*",
58+
"utopia-php/dns": "0.3.*",
5859
"utopia-php/dsn": "0.2.1",
5960
"utopia-php/framework": "0.33.*",
6061
"utopia-php/fetch": "0.4.*",

composer.lock

Lines changed: 59 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ services:
120120
- _APP_DOMAIN_TARGET_CNAME
121121
- _APP_DOMAIN_TARGET_AAAA
122122
- _APP_DOMAIN_TARGET_A
123+
- _APP_DOMAIN_TARGET_CAA
124+
- _APP_DNS
123125
- _APP_DOMAIN_FUNCTIONS
124126
- _APP_REDIS_HOST
125127
- _APP_REDIS_PORT
@@ -535,6 +537,8 @@ services:
535537
- _APP_DOMAIN_TARGET_CNAME
536538
- _APP_DOMAIN_TARGET_AAAA
537539
- _APP_DOMAIN_TARGET_A
540+
- _APP_DOMAIN_TARGET_CAA
541+
- _APP_DNS
538542
- _APP_DOMAIN_FUNCTIONS
539543
- _APP_EMAIL_CERTIFICATES
540544
- _APP_REDIS_HOST
@@ -704,6 +708,8 @@ services:
704708
- _APP_DOMAIN_TARGET_CNAME
705709
- _APP_DOMAIN_TARGET_AAAA
706710
- _APP_DOMAIN_TARGET_A
711+
- _APP_DOMAIN_TARGET_CAA
712+
- _APP_DNS
707713
- _APP_EMAIL_SECURITY
708714
- _APP_REDIS_HOST
709715
- _APP_REDIS_PORT
@@ -738,6 +744,8 @@ services:
738744
- _APP_DOMAIN_TARGET_CNAME
739745
- _APP_DOMAIN_TARGET_AAAA
740746
- _APP_DOMAIN_TARGET_A
747+
- _APP_DOMAIN_TARGET_CAA
748+
- _APP_DNS
741749
- _APP_DOMAIN_FUNCTIONS
742750
- _APP_OPENSSL_KEY_V1
743751
- _APP_REDIS_HOST

src/Appwrite/Network/Validator/DNS.php

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,38 @@
22

33
namespace Appwrite\Network\Validator;
44

5+
use Utopia\DNS\Client;
6+
use Utopia\Domains\Domain;
7+
use Utopia\System\System;
58
use Utopia\Validator;
69

710
class DNS extends Validator
811
{
9-
public const RECORD_A = 'a';
10-
public const RECORD_AAAA = 'aaaa';
11-
public const RECORD_CNAME = 'cname';
12+
public const RECORD_A = 'A';
13+
public const RECORD_AAAA = 'AAAA';
14+
public const RECORD_CNAME = 'CNAME';
15+
public const RECORD_CAA = 'CAA'; // You can provide domain only (as $target) for CAA validation
1216

1317
/**
1418
* @var mixed
1519
*/
1620
protected mixed $logs;
1721

22+
/**
23+
* @var string
24+
*/
25+
protected string $dnsServer;
26+
1827
/**
1928
* @param string $target
2029
*/
21-
public function __construct(protected string $target, protected string $type = self::RECORD_CNAME)
30+
public function __construct(protected string $target, protected string $type = self::RECORD_CNAME, string $dnsServer = '')
2231
{
32+
if (empty($dnsServer)) {
33+
$dnsServer = System::getEnv('_APP_DNS', '8.8.8.8');
34+
}
35+
36+
$this->dnsServer = $dnsServer;
2337
}
2438

2539
/**
@@ -42,42 +56,65 @@ public function getLogs(): mixed
4256
* Check if DNS record value matches specific value
4357
*
4458
* @param mixed $domain
45-
*
4659
* @return bool
4760
*/
4861
public function isValid($value): bool
4962
{
50-
$typeNative = match ($this->type) {
51-
self::RECORD_A => DNS_A,
52-
self::RECORD_AAAA => DNS_AAAA,
53-
self::RECORD_CNAME => DNS_CNAME,
54-
default => throw new \Exception('Record type not supported.')
55-
};
56-
57-
$dnsKey = match ($this->type) {
58-
self::RECORD_A => 'ip',
59-
self::RECORD_AAAA => 'ipv6',
60-
self::RECORD_CNAME => 'target',
61-
default => throw new \Exception('Record type not supported.')
62-
};
63-
6463
if (!is_string($value)) {
6564
return false;
6665
}
6766

67+
$dns = new Client($this->dnsServer);
68+
6869
try {
69-
$records = \dns_get_record($value, $typeNative);
70-
$this->logs = $records;
71-
} catch (\Throwable $th) {
70+
$rawQuery = $dns->query($value, $this->type);
71+
72+
// Some DNS servers return all records, not only type that's asked for
73+
// Likely occurs when no records of specific type are found
74+
$query = array_filter($rawQuery, function ($record) {
75+
return $record->getTypeName() === $this->type;
76+
});
77+
78+
$this->logs = $query;
79+
} catch (\Exception $e) {
80+
$this->logs = ['error' => $e->getMessage()];
7281
return false;
7382
}
7483

75-
if (!$records) {
84+
if (empty($query)) {
85+
// CAA records inherit from parent (custom CAA behaviour)
86+
if ($this->type === self::RECORD_CAA) {
87+
$domain = new Domain($value);
88+
if ($domain->get() === $domain->getApex()) {
89+
return true; // No CAA on apex domain means anyone can issue certificate
90+
}
91+
92+
// Recursive validation by parent domain
93+
$parts = \explode('.', $value);
94+
\array_shift($parts);
95+
$parentDomain = \implode('.', $parts);
96+
$validator = new DNS($this->target, DNS::RECORD_CAA, $this->dnsServer);
97+
return $validator->isValid($parentDomain);
98+
}
99+
76100
return false;
77101
}
78102

79-
foreach ($records as $record) {
80-
if (isset($record[$dnsKey]) && $record[$dnsKey] === $this->target) {
103+
foreach ($query as $record) {
104+
// CAA validation only needs to ensure domain
105+
if ($this->type === self::RECORD_CAA) {
106+
// Extract domain; comments showcase extraction steps in most complex scenario
107+
$rdata = $record->getRdata(); // 255 issuewild "certainly.com;validationmethods=tls-alpn-01;retrytimeout=3600"
108+
$rdata = \explode(' ', $rdata, 3)[2] ?? ''; // "certainly.com;validationmethods=tls-alpn-01;retrytimeout=3600"
109+
$rdata = \trim($rdata, '"'); // certainly.com;validationmethods=tls-alpn-01;retrytimeout=3600
110+
$rdata = \explode(';', $rdata, 2)[0] ?? ''; // certainly.com
111+
112+
if ($rdata === $this->target) {
113+
return true;
114+
}
115+
}
116+
117+
if ($record->getRdata() === $this->target) {
81118
return true;
82119
}
83120
}

src/Appwrite/Platform/Workers/Certificates.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,19 @@ private function validateDomain(Domain $domain, bool $isMainDomain, Log $log): v
337337

338338
throw new Exception('Failed to verify domain DNS records.');
339339
}
340+
341+
// Ensure CAA won't block certificate issuance
342+
if (!empty(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''))) {
343+
$validationStart = \microtime(true);
344+
$validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''), DNS::RECORD_CAA);
345+
if (!$validator->isValid($domain->get())) {
346+
$log->addExtra('dnsTimingCaa', \strval(\microtime(true) - $validationStart));
347+
$log->addTag('dnsDomain', $domain->get());
348+
$error = $validator->getDescription();
349+
$log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error));
350+
throw new Exception('Failed to verify domain DNS records. CAA records do not allow Appwrite\'s certificate issuer.');
351+
}
352+
}
340353
} else {
341354
// Main domain validation
342355
// TODO: Would be awesome to check A/AAAA record here. Maybe dry run?

0 commit comments

Comments
 (0)