diff --git a/CHANGELOG-5.12.md b/CHANGELOG-5.12.md index 3a9ea58afb2..92a2f86be9c 100644 --- a/CHANGELOG-5.12.md +++ b/CHANGELOG-5.12.md @@ -41,6 +41,8 @@ - Added support for JPEG XL (`.jxl`) images, when ImageMagick supports it. JPEG XL images can now be uploaded, transformed, and set as a transform’s output format. - BMP images can now be transformed and edited in the Image Editor. +- MySQL database backups and restores now carry over SSL/TLS settings from the system’s MySQL option files (e.g. `ssl-ca` or `disable-ssl-verify-server-cert`), if the database connection doesn’t have any `PDO::MYSQL_ATTR_SSL_*` attributes set. ([#19366](https://github.com/craftcms/cms/pull/19366)) +- MySQL database backups and restores now respect the `PDO::MYSQL_ATTR_SSL_CAPATH` and `PDO::MYSQL_ATTR_SSL_CIPHER` database connection attributes. ([#19366](https://github.com/craftcms/cms/pull/19366)) - Craft can now determine the dimensions of BMP and JPEG XL images on remote volumes without downloading them. - Removed support for the non-standard `ED256` passkey algorithm. ([#19701](https://github.com/craftcms/cms/pull/19701)) - Updated Axios to 1.20.0. diff --git a/src/db/mysql/Schema.php b/src/db/mysql/Schema.php index f923de08e3b..e66ab760484 100644 --- a/src/db/mysql/Schema.php +++ b/src/db/mysql/Schema.php @@ -197,7 +197,7 @@ public function createColumnSchemaBuilder($type, $length = null): ColumnSchemaBu public function getDefaultBackupCommand(?array $ignoreTables = null): string { $baseCommand = (new ShellCommand('mysqldump')) - ->addArg('--defaults-file=', $this->_createDumpConfigFile()) + ->addArg('--defaults-file=', $this->_createDumpConfigFile('mysqldump')) ->addArg('--add-drop-table') ->addArg('--comments') ->addArg('--create-options') @@ -266,7 +266,7 @@ public function getDefaultRestoreCommand(): string { $commandFromConfig = Craft::$app->getConfig()->getGeneral()->restoreCommand; $command = (new ShellCommand('mysql')) - ->addArg('--defaults-file=', $this->_createDumpConfigFile()) + ->addArg('--defaults-file=', $this->_createDumpConfigFile('mysql')) ->addArg('{database}'); if ($commandFromConfig instanceof \Closure) { @@ -455,10 +455,11 @@ protected function supportsColumnStatistics(): bool /** * Creates a temporary my.cnf file based on the DB config settings. * + * @param string $program The program that the my.cnf file will be used with (`mysqldump` or `mysql`) * @return string The path to the my.cnf file * @throws ErrorException */ - private function _createDumpConfigFile(): string + private function _createDumpConfigFile(string $program): string { $this->tempMyCnfPath = FileHelper::normalizePath(sys_get_temp_dir()) . DIRECTORY_SEPARATOR . StringHelper::randomString(12) . '.cnf'; @@ -477,14 +478,33 @@ private function _createDumpConfigFile(): string } // Certificates - if (isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CA])) { - $contents .= PHP_EOL . 'ssl_ca=' . $this->db->attributes[PDO::MYSQL_ATTR_SSL_CA]; - } - if (isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CERT])) { - $contents .= PHP_EOL . 'ssl_cert=' . $this->db->attributes[PDO::MYSQL_ATTR_SSL_CERT]; - } - if (isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_KEY])) { - $contents .= PHP_EOL . 'ssl_key=' . $this->db->attributes[PDO::MYSQL_ATTR_SSL_KEY]; + if ( + isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CA]) || + (defined('PDO::MYSQL_ATTR_SSL_CAPATH') && isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CAPATH])) || + isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CERT]) || + isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_KEY]) || + (defined('PDO::MYSQL_ATTR_SSL_CIPHER') && isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CIPHER])) + ) { + if (isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CA])) { + $contents .= PHP_EOL . 'ssl_ca=' . $this->db->attributes[PDO::MYSQL_ATTR_SSL_CA]; + } + if (defined('PDO::MYSQL_ATTR_SSL_CAPATH') && isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CAPATH])) { + $contents .= PHP_EOL . 'ssl_capath=' . $this->db->attributes[PDO::MYSQL_ATTR_SSL_CAPATH]; + } + if (isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CERT])) { + $contents .= PHP_EOL . 'ssl_cert=' . $this->db->attributes[PDO::MYSQL_ATTR_SSL_CERT]; + } + if (isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_KEY])) { + $contents .= PHP_EOL . 'ssl_key=' . $this->db->attributes[PDO::MYSQL_ATTR_SSL_KEY]; + } + if (defined('PDO::MYSQL_ATTR_SSL_CIPHER') && isset($this->db->attributes[PDO::MYSQL_ATTR_SSL_CIPHER])) { + $contents .= PHP_EOL . 'ssl_cipher=' . $this->db->attributes[PDO::MYSQL_ATTR_SSL_CIPHER]; + } + } else { + // The db connection wasn't explicitly configured with SSL attributes, but the normal + // my.cnf file chain (which --defaults-file causes mysqldump/mysql to ignore) might + // still specify them, so carry those over. + $contents .= $this->_sslDefaultsFromConfigFiles($program); } FileHelper::writeToFile($this->tempMyCnfPath, ''); @@ -495,6 +515,85 @@ private function _createDumpConfigFile(): string return $this->tempMyCnfPath; } + /** + * Returns SSL/TLS my.cnf directives based on whatever the normal MySQL config file chain + * (e.g. `/etc/my.cnf`, `~/.my.cnf`) would otherwise provide, since those files are ignored + * once `--defaults-file` is passed to `mysqldump`/`mysql`. + * + * @param string $program The program whose defaults should be checked (`mysqldump` or `mysql`) + * @return string + */ + private function _sslDefaultsFromConfigFiles(string $program): string + { + $shellCommand = (new ShellCommand($program))->addArg('--print-defaults'); + + // If we don't have proc_open, maybe we've got exec + if (!function_exists('proc_open') && function_exists('exec')) { + $shellCommand->useExec = true; + } + + if (!$shellCommand->execute()) { + return ''; + } + + return $this->_sslDirectivesFromPrintDefaults($shellCommand->getOutput() ?? ''); + } + + /** + * Returns SSL/TLS my.cnf directives from the given `--print-defaults` output. + * + * @param string $output + * @return string + */ + private function _sslDirectivesFromPrintDefaults(string $output): string + { + // The output looks like: + // + // mysqldump would have been started with the following arguments: + // --ssl-ca=/path/to/ca.pem --ssl-verify-server-cert=0 --skip-ssl ... + // + // Values aren't quoted, so split on whitespace that is followed by `--` (rather than on all whitespace), + // so values containing spaces stay intact. + $pos = strpos($output, '--'); + if ($pos === false) { + return ''; + } + + $tokens = preg_split('/\s+(?=--)/', trim(substr($output, $pos)), -1, PREG_SPLIT_NO_EMPTY); + + $contents = ''; + + foreach ($tokens as $token) { + if (!preg_match('/^--([\w-]+)(?:=(.*))?$/s', trim($token), $match)) { + continue; + } + + // Option names can use dashes or underscores, and can be prefixed with modifiers like `skip-` and `loose-` + $name = strtolower(str_replace('_', '-', $match[1])); + $baseName = preg_replace('/^(?:loose-)?(?:skip-|disable-|enable-)?/', '', $name); + if (!preg_match('/^(ssl|tls)(-|$)/', $baseName)) { + continue; + } + + if (!isset($match[2])) { + $contents .= PHP_EOL . $name; + continue; + } + + $value = trim($match[2], "\"'"); + + // Ignore redacted values + if (preg_match('/^\*+$/', $value)) { + continue; + } + + // Backslashes are escape characters in option files (e.g. within Windows paths) + $contents .= PHP_EOL . $name . '=' . str_replace('\\', '\\\\', $value); + } + + return $contents; + } + /** * Returns the row format for the given table, if known. * diff --git a/tests/unit/db/mysql/SchemaTest.php b/tests/unit/db/mysql/SchemaTest.php new file mode 100644 index 00000000000..6023b0eb833 --- /dev/null +++ b/tests/unit/db/mysql/SchemaTest.php @@ -0,0 +1,78 @@ + + * @since 5.12.0 + */ +class SchemaTest extends TestCase +{ + /** + * @dataProvider sslDirectivesFromPrintDefaultsDataProvider + * @param string $expected + * @param string $output + */ + public function testSslDirectivesFromPrintDefaults(string $expected, string $output): void + { + $schema = Craft::$app->getDb()->getSchema(); + if (!$schema instanceof Schema) { + $this->markTestSkipped('The test database isn’t MySQL.'); + } + + self::assertSame($expected, $this->invokeMethod($schema, '_sslDirectivesFromPrintDefaults', [$output])); + } + + public static function sslDirectivesFromPrintDefaultsDataProvider(): array + { + $header = 'mysqldump would have been started with the following arguments:' . PHP_EOL; + $nl = PHP_EOL; + + return [ + 'empty' => ['', ''], + 'header only' => ['', $header], + 'no ssl options' => ['', $header . '--host=localhost --user=root --password=***** --quick'], + 'basic' => [ + "{$nl}ssl-ca=/etc/ssl/ca.pem{$nl}ssl-cert=/etc/ssl/cert.pem{$nl}ssl-key=/etc/ssl/key.pem", + $header . '--host=db --ssl-ca=/etc/ssl/ca.pem --ssl-cert=/etc/ssl/cert.pem --ssl-key=/etc/ssl/key.pem --quick', + ], + 'flags and prefixes' => [ + "{$nl}skip-ssl{$nl}disable-ssl-verify-server-cert{$nl}loose-ssl-mode=REQUIRED{$nl}ssl=0", + $header . '--skip-ssl --disable-ssl-verify-server-cert --loose-ssl-mode=REQUIRED --ssl=0', + ], + 'verification and tls' => [ + "{$nl}skip-ssl=true{$nl}ssl-verify-server-cert=0{$nl}tls-version=TLSv1.2,TLSv1.3", + $header . '--skip-ssl=true --ssl-verify-server-cert=0 --tls-version=TLSv1.2,TLSv1.3', + ], + 'underscores' => [ + "{$nl}ssl-ca=/etc/ssl/ca.pem", + $header . '--ssl_ca=/etc/ssl/ca.pem', + ], + 'spaces in values' => [ + "{$nl}ssl-ca=/path/with spaces/ca.pem{$nl}ssl-key=/other path/key.pem", + $header . '--ssl-ca=/path/with spaces/ca.pem --quick --ssl-key=/other path/key.pem', + ], + 'windows paths' => [ + "{$nl}ssl-ca=C:\\\\Program Files\\\\MySQL\\\\ca.pem", + $header . '--ssl-ca=C:\\Program Files\\MySQL\\ca.pem', + ], + 'quoted values' => [ + "{$nl}ssl-cipher=AES256-SHA", + $header . '--ssl-cipher="AES256-SHA"', + ], + 'redacted values' => ['', $header . '--ssl-key=*****'], + 'not ssl' => ['', $header . '--sslx=1 --skip-sslx --tlsfoo=bar'], + ]; + } +}