Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ _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
_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
Expand Down
18 changes: 18 additions & 0 deletions app/config/variables.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
13 changes: 13 additions & 0 deletions app/controllers/api/proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 Appwrite\'s certificate issuer.');
}
}

$dbForPlatform->updateDocument('rules', $rule->getId(), $rule->setAttribute('status', 'verifying'));

// Issue a TLS certificate when domain is verified
Expand Down
8 changes: 8 additions & 0 deletions app/views/install/compose.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -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_DNS
- _APP_DOMAIN_FUNCTIONS
- _APP_REDIS_HOST
- _APP_REDIS_PORT
Expand Down Expand Up @@ -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_DNS
- _APP_DOMAIN_FUNCTIONS
- _APP_EMAIL_CERTIFICATES
- _APP_REDIS_HOST
Expand Down Expand Up @@ -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_DNS
- _APP_EMAIL_SECURITY
- _APP_REDIS_HOST
- _APP_REDIS_PORT
Expand Down Expand Up @@ -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_DNS
- _APP_DOMAIN_FUNCTIONS
- _APP_OPENSSL_KEY_V1
- _APP_REDIS_HOST
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.*",
Expand Down
62 changes: 59 additions & 3 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
87 changes: 62 additions & 25 deletions src/Appwrite/Network/Validator/DNS.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,38 @@

namespace Appwrite\Network\Validator;

use Utopia\DNS\Client;
use Utopia\Domains\Domain;
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
*/
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;
}

/**
Expand All @@ -42,42 +56,65 @@ 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;
}

$dns = new Client($this->dnsServer);

try {
$records = \dns_get_record($value, $typeNative);
$this->logs = $records;
} catch (\Throwable $th) {
$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()];
return false;
}

if (!$records) {
if (empty($query)) {
// CAA records inherit from parent (custom CAA behaviour)
if ($this->type === self::RECORD_CAA) {
$domain = new Domain($value);
if ($domain->get() === $domain->getApex()) {
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($this->target, DNS::RECORD_CAA, $this->dnsServer);
return $validator->isValid($parentDomain);
}

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;
}
}
Expand Down
13 changes: 13 additions & 0 deletions src/Appwrite/Platform/Workers/Certificates.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 Appwrite\'s certificate issuer.');
}
}
} else {
// Main domain validation
// TODO: Would be awesome to check A/AAAA record here. Maybe dry run?
Expand Down
Loading