diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 0e6fd4e3e..d50e49fd9 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -485,10 +485,10 @@ private function importDatabaseDump(string $localDumpFilepath, string $dbHost, s 'mysql', ]); if ($this->localMachineHelper->commandExists('pv')) { - $command = 'pv "${:LOCAL_DUMP_FILEPATH}" --bytes --rate | gunzip | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"'; + $command = 'bash -o pipefail -c "pv \\"$LOCAL_DUMP_FILEPATH\\" --bytes --rate | gunzip | MYSQL_PWD=\\"$MYSQL_PASSWORD\\" mysql --host=\\"$MYSQL_HOST\\" --user=\\"$MYSQL_USER\\" \\"$MYSQL_DATABASE\\""'; } else { $this->io->warning('Install `pv` to see progress bar'); - $command = 'gunzip -c "${:LOCAL_DUMP_FILEPATH}" | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"'; + $command = 'bash -o pipefail -c "gunzip -c \\"$LOCAL_DUMP_FILEPATH\\" | MYSQL_PWD=\\"$MYSQL_PASSWORD\\" mysql --host=\\"$MYSQL_HOST\\" --user=\\"$MYSQL_USER\\" \\"$MYSQL_DATABASE\\""'; } $env = [ diff --git a/src/Command/Push/PushDatabaseCommand.php b/src/Command/Push/PushDatabaseCommand.php index 8104a147c..94b7f7f51 100644 --- a/src/Command/Push/PushDatabaseCommand.php +++ b/src/Command/Push/PushDatabaseCommand.php @@ -95,10 +95,21 @@ private function uploadDatabaseDump( return $remoteFilepath; } + /** + * Import database dump on remote server. + * Manually escapes single quotes for bash command safety. + */ private function importDatabaseDumpOnRemote(EnvironmentResponse $environment, string $remoteDumpFilepath, DatabaseResponse $database): void { $this->logger->debug("Importing $remoteDumpFilepath to MySQL on remote machine"); - $command = "pv $remoteDumpFilepath --bytes --rate | gunzip | MYSQL_PWD=$database->password mysql --host={$this->getHostFromDatabaseResponse($environment, $database)} --user=$database->user_name {$this->getNameFromDatabaseResponse($database)}"; + // Manually escape for single-quoted bash strings (replace ' with '\'' - end quote, escaped quote, start quote) + $host = str_replace("'", "'\\''", $this->getHostFromDatabaseResponse($environment, $database)); + $user = str_replace("'", "'\\''", $database->user_name); + $dbName = str_replace("'", "'\\''", $this->getNameFromDatabaseResponse($database)); + $password = str_replace("'", "'\\''", $database->password); + // @infection-ignore-all System-generated path under our control, defensive escaping + $filepath = str_replace("'", "'\\''", $remoteDumpFilepath); + $command = "bash -o pipefail -c 'pv '$filepath' --bytes --rate | gunzip | MYSQL_PWD='$password' mysql --host='$host' --user='$user' '$dbName''"; $process = $this->sshHelper->executeCommand($environment->sshUrl, [$command], ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL)); if (!$process->isSuccessful()) { throw new AcquiaCliException('Unable to import database on remote machine. {message}', ['message' => $process->getErrorOutput()]); diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 1c3a61308..2c3fb295b 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -335,7 +335,7 @@ protected function mockExecuteMySqlImport( $this->mockExecutePvExists($localMachineHelper, $pvExists); $process = $this->mockProcess($success); $filePath = Path::join(sys_get_temp_dir(), "$env-$dbName-$dbMachineName-$createdAt.sql.gz"); - $command = $pvExists ? 'pv "${:LOCAL_DUMP_FILEPATH}" --bytes --rate | gunzip | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"' : 'gunzip -c "${:LOCAL_DUMP_FILEPATH}" | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"'; + $command = $pvExists ? 'bash -o pipefail -c "pv \\"$LOCAL_DUMP_FILEPATH\\" --bytes --rate | gunzip | MYSQL_PWD=\\"$MYSQL_PASSWORD\\" mysql --host=\\"$MYSQL_HOST\\" --user=\\"$MYSQL_USER\\" \\"$MYSQL_DATABASE\\""' : 'bash -o pipefail -c "gunzip -c \\"$LOCAL_DUMP_FILEPATH\\" | MYSQL_PWD=\\"$MYSQL_PASSWORD\\" mysql --host=\\"$MYSQL_HOST\\" --user=\\"$MYSQL_USER\\" \\"$MYSQL_DATABASE\\""'; $expectedEnv = [ 'LOCAL_DUMP_FILEPATH' => $filePath, 'MYSQL_DATABASE' => $localDbName, diff --git a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php index 9562f1197..03d788389 100644 --- a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php @@ -200,7 +200,79 @@ private function mockImportDatabaseDumpOnRemote(ObjectProphecy|LocalMachineHelpe 3 => '-o StrictHostKeyChecking=no', 4 => '-o AddressFamily inet', 5 => '-o LogLevel=ERROR', - 6 => 'pv /mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz --bytes --rate | gunzip | MYSQL_PWD=password mysql --host=fsdb-74.enterprise-g1.hosting.acquia.com.enterprise-g1.hosting.acquia.com --user=s164 profserv2db14390', + 6 => "bash -o pipefail -c 'pv '/mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz' --bytes --rate | gunzip | MYSQL_PWD='password' mysql --host='fsdb-74.enterprise-g1.hosting.acquia.com.enterprise-g1.hosting.acquia.com' --user='s164' 'profserv2db14390''", + ]; + $localMachineHelper->execute($cmd, Argument::type('callable'), null, $printOutput, null, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + } + + /** + * Test that special characters in passwords are properly escaped. + */ + public function testPushDatabaseWithSpecialCharsInPassword(): void + { + $applications = $this->mockRequest('getApplications'); + $application = $this->mockRequest('getApplicationByUuid', $applications[self::$INPUT_DEFAULT_CHOICE]->uuid); + $tamper = function ($responses): void { + foreach ($responses as $response) { + $response->ssh_url = 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com'; + $response->domains = ["profserv201dev.enterprise-g1.acquia-sites.com"]; + } + }; + $environments = $this->mockRequest('getApplicationEnvironments', $application->uuid, null, null, $tamper); + $this->createMockGitConfigFile(); + + // Mock database with special characters in password, username, hostname, and database name. + $databases = $this->mockAcsfDatabasesResponse($environments[self::$INPUT_DEFAULT_CHOICE]); + $databases[0]->password = "pass'word"; + $databases[0]->user_name = "user'name"; + $databases[0]->db_host = "db'host"; + $databases[0]->url = "mysqli://s164:password@127.0.0.1:3306/db'name"; + + $process = $this->mockProcess(); + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->checkRequiredBinariesExist(['ssh']) + ->shouldBeCalled(); + $this->mockGetAcsfSitesLMH($localMachineHelper); + + $this->mockExecutePvExists($localMachineHelper, true); + $this->mockCreateMySqlDumpOnLocal($localMachineHelper, true, true); + $this->mockUploadDatabaseDump($localMachineHelper, $process, true); + $this->mockImportDatabaseDumpOnRemoteWithSpecialChars($localMachineHelper, $process, true); + + $this->command->sshHelper = new SshHelper($this->output, $localMachineHelper->reveal(), $this->logger); + + $inputs = [ + // Would you like Acquia CLI to search for a Cloud application? + 'n', + // Select a Cloud Platform application. + 0, + // Would you like to link the project? + 'n', + // Choose a Cloud Platform environment. + 0, + // Choose a database. + 0, + // Overwrite the database? + 'y', + ]; + + $this->executeCommand([], $inputs, OutputInterface::VERBOSITY_VERY_VERBOSE); + $this->prophet->checkPredictions(); + } + + private function mockImportDatabaseDumpOnRemoteWithSpecialChars(ObjectProphecy|LocalMachineHelper $localMachineHelper, Process|ObjectProphecy $process, bool $printOutput = true): void + { + // Verify the command has properly escaped single quotes using '\'' pattern. + $cmd = [ + 0 => 'ssh', + 1 => 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com', + 2 => '-t', + 3 => '-o StrictHostKeyChecking=no', + 4 => '-o AddressFamily inet', + 5 => '-o LogLevel=ERROR', + 6 => "bash -o pipefail -c 'pv '/mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz' --bytes --rate | gunzip | MYSQL_PWD='pass'\\''word' mysql --host='db'\\''host.enterprise-g1.hosting.acquia.com' --user='user'\\''name' 'db'\\''name''", ]; $localMachineHelper->execute($cmd, Argument::type('callable'), null, $printOutput, null, null) ->willReturn($process->reveal())