Installing and Upgrading Plugins Without the Exec() Function

Describe the problem you would like to solve
The plugin needs to call the PHP exec() function to run the tar command on the server, which is used to extract files from the plugin archive (.tar.gz). However, if the exec() function is disabled on the hosting, this process will fail.

Describe the solution you’d like
Due to security considerations, the exec() function is commonly disabled in many environments. Therefore, the recommended approach is to refactor the code, replacing the exec() call with PHP’s native PharData class to manage decompression and extraction tasks.

OJS 3.3x
Original function on: /lib/pkp/classes/plugins/PluginHelper.inc.php

public function extractPlugin($filePath, $originalFileName) {
		$fileManager = new FileManager();
		// tar archive basename (less potential version number) must
		// equal plugin directory name and plugin files must be in a
		// directory named after the plug-in (potentially with version)
		$matches = array();
		PKPString::regexp_match_get('/^[a-zA-Z0-9]+/', basename($originalFileName, '.tar.gz'), $matches);
		$pluginShortName = array_pop($matches);
		if (!$pluginShortName) {
			throw new Exception(__('manager.plugins.invalidPluginArchive'));
		}

		// Create random dirname to avoid symlink attacks.
		$pluginExtractDir = dirname($filePath) . DIRECTORY_SEPARATOR . $pluginShortName . substr(md5(random_int(0, PHP_INT_MAX)), 0, 10);
		if (!mkdir($pluginExtractDir)) throw new Exception('Could not create directory ' . $pluginExtractDir);

		// Test whether the tar binary is available for the export to work
		$tarBinary = Config::getVar('cli', 'tar');
		if (empty($tarBinary) || !file_exists($tarBinary)) {
			rmdir($pluginExtractDir);
			throw new Exception(__('manager.plugins.tarCommandNotFound'));
		}

		$output = '';
		$returnCode = 0;
		if (in_array('exec', explode(',', ini_get('disable_functions')))) throw new Exception('The "exec" PHP function has been disabled on your server. Contact your system adminstrator to enable it.');
		exec($tarBinary.' -xzf ' . escapeshellarg($filePath) . ' -C ' . escapeshellarg($pluginExtractDir), $output, $returnCode);
		if ($returnCode) {
			$fileManager->rmtree($pluginExtractDir);
			throw new Exception(__('form.dropzone.dictInvalidFileType'));
		}

		// Look for a directory named after the plug-in's short
		// (alphanumeric) name within the extracted archive.
		if (is_dir($tryDir = $pluginExtractDir . '/' . $pluginShortName)) {
			return $tryDir; // Success
		}

		// Failing that, look for a directory named after the
		// archive. (Typically also contains the version number
		// e.g. with github generated release archives.)
		PKPString::regexp_match_get('/^[a-zA-Z0-9.-]+/', basename($originalFileName, '.tar.gz'), $matches);
		if (is_dir($tryDir = $pluginExtractDir . '/' . array_pop($matches))) {
			// We found a directory named after the archive
			// within the extracted archive. (Typically also
			// contains the version number, e.g. github
			// generated release archives.)
			return $tryDir;
		}

		// Could not match the plugin archive's contents against our expectations; error out.
		$fileManager->rmtree($pluginExtractDir);
		throw new Exception(__('manager.plugins.invalidPluginArchive'));
	}

Update function on:/lib/pkp/classes/plugins/PluginHelper.inc.php

public function extractPlugin($filePath, $originalFileName) {
		$fileManager = new FileManager();
		// tar archive basename (less potential version number) must
		// equal plugin directory name and plugin files must be in a
		// directory named after the plug-in (potentially with version)
		$matches = array();
		PKPString::regexp_match_get('/^[a-zA-Z0-9]+/', basename($originalFileName, '.tar.gz'), $matches);
		$pluginShortName = array_pop($matches);
		if (!$pluginShortName) {
			throw new Exception(__('manager.plugins.invalidPluginArchive'));
		}

		/ Create random dirname to avoid symlink attacks.
		$pluginExtractDir = dirname($filePath) . DIRECTORY_SEPARATOR . $pluginShortName . substr(md5(random_int(0, PHP_INT_MAX)), 0, 10);
		if (!mkdir($pluginExtractDir)) throw new Exception('Could not create directory ' . $pluginExtractDir);

		// ====================================================================
		// REPLACED: Fallback from exec() to PharData
		// ====================================================================

		// Rename the temporary file with .tar.gz extension
		$tempTarPath = $filePath . '.tar.gz';
		if (!rename($filePath, $tempTarPath)) {
			$fileManager->rmtree($pluginExtractDir);
			throw new Exception('Could not rename temporary file for extraction.');
		}

		try {
			// Use renamed file paths
			$phar = new PharData($tempTarPath);
			// Extracts archive contents to destination directory
			$phar->extractTo($pluginExtractDir);
		} catch (Exception $e) {
			// If an error occurs during extraction, delete the temporary directory and display a message.
			$fileManager->rmtree($pluginExtractDir);
			throw new Exception(__('form.dropzone.dictInvalidFileType') . ' (' . $e->getMessage() . ')');
		} finally {
			// Make sure the temporary .tar.gz file is always deleted
			if (file_exists($tempTarPath)) {
				unlink($tempTarPath);
			}
		}
		// ====================================================================
		// END OF CHANGE
		// ====================================================================

		// Look for a directory named after the plug-in's short
		// (alphanumeric) name within the extracted archive.
		if (is_dir($tryDir = $pluginExtractDir . '/' . $pluginShortName)) {
			return $tryDir; // Success
		}

		// Failing that, look for a directory named after the
		// archive. (Typically also contains the version number
		// e.g. with github generated release archives.)
		PKPString::regexp_match_get('/^[a-zA-Z0-9.-]+/', basename($originalFileName, '.tar.gz'), $matches);
		if (is_dir($tryDir = $pluginExtractDir . '/' . array_pop($matches))) {
			// We found a directory named after the archive
			// within the extracted archive. (Typically also
			// contains the version number, e.g. github
			// generated release archives.)
			return $tryDir;
		}

		// Could not match the plugin archive's contents against our expectations; error out.
		$fileManager->rmtree($pluginExtractDir);
		throw new Exception(__('manager.plugins.invalidPluginArchive'));
	}

Hi @afifsh,

This change has already been made beginning with OJS/OMP/OPS 3.4.0 – see:

https://github.com/pkp/pkp-lib/issues/6077

There are other remaining requirements for the exec function, however; they are listed here:

https://github.com/pkp/pkp-lib/issues/7083

Regards,
Alec Smecher
Public Knowledge Project Team

1 Like

This topic was automatically closed after 13 days. New replies are no longer allowed.