diff --git a/core/includes/bootstrap.inc b/core/includes/bootstrap.inc index 23f8416..fa5b9d4 100644 --- a/core/includes/bootstrap.inc +++ b/core/includes/bootstrap.inc @@ -3543,3 +3543,45 @@ function drupal_check_memory_limit($required, $memory_limit = NULL) { // the operation. return ((!$memory_limit) || ($memory_limit == -1) || (parse_size($memory_limit) >= parse_size($required))); } + +/** + * Instantiates and statically caches a storage controller for generated PHP code. + * + * By default, this returns an instance of the + * Drupal\Component\PhpStorage\MTimeProtectedFileStorage class. + * + * Classes implementing + * Drupal\Component\PhpStorage\PhpStorageInterface can be registered for a + * specific bin or as a default implementation. + * + * @param $bin + * The bin for which the storage controller should be returned. Defaults to + * 'default'. + * + * @return Drupal\Component\PhpStorage\PhpStorageInterface + * An instantiated storage controller for the specified bin. + * + * @see Drupal\Component\PhpStorage\PhpStorageInterface + */ +function drupal_php_storage($bin = 'default') { + global $conf; + $storage_controllers = &drupal_static(__FUNCTION__); + if (!isset($storage_controllers[$bin])) { + if (isset($conf['php_storage'][$bin])) { + $configuration = $conf['php_storage'][$bin]; + } + elseif (isset($conf['php_storage']['default'])) { + $configuration = $conf['php_storage']['default']; + } + else { + $configuration = array( + 'class' => 'Drupal\Component\PhpStorage\MTimeProtectedFileStorage', + 'directory' => DRUPAL_ROOT . '/' . variable_get('file_public_path', conf_path() . '/files') . '/php', + 'secret' => $GLOBALS['drupal_hash_salt'], + ); + } + $class = $configuration['class']; + $storage_controllers[$bin] = new $class($configuration, $bin); + } + return $storage_controllers[$bin]; +} diff --git a/core/includes/file.inc b/core/includes/file.inc index 133d64f..b8e9847 100644 --- a/core/includes/file.inc +++ b/core/includes/file.inc @@ -1325,6 +1325,10 @@ function file_unmanaged_delete($path) { * * @param $path * A string containing either an URI or a file or directory path. + * @param $callback + * (optional) Callback function to run on each file prior to deleting it and + * on each directory prior to traversing it. For example, can be used to + * modify permissions. * * @return * TRUE for success or if path does not exist, FALSE in the event of an @@ -1332,7 +1336,10 @@ function file_unmanaged_delete($path) { * * @see file_unmanaged_delete() */ -function file_unmanaged_delete_recursive($path) { +function file_unmanaged_delete_recursive($path, $callback = NULL) { + if (isset($callback)) { + call_user_func($callback, $path); + } if (is_dir($path)) { $dir = dir($path); while (($entry = $dir->read()) !== FALSE) { @@ -1340,7 +1347,7 @@ function file_unmanaged_delete_recursive($path) { continue; } $entry_path = $path . '/' . $entry; - file_unmanaged_delete_recursive($entry_path); + file_unmanaged_delete_recursive($entry_path, $callback); } $dir->close(); diff --git a/core/lib/Drupal/Component/PhpStorage/FileStorage.php b/core/lib/Drupal/Component/PhpStorage/FileStorage.php new file mode 100644 index 0000000..98008c0 --- /dev/null +++ b/core/lib/Drupal/Component/PhpStorage/FileStorage.php @@ -0,0 +1,77 @@ +directory = $configuration['directory'] . '/' . $bin; + } + + /** + * Implements Drupal\Component\PhpStorage\PhpStorageInterface::exists(). + */ + public function exists($name) { + return file_exists($this->getFullPath($name)); + } + + /** + * Implements Drupal\Component\PhpStorage\PhpStorageInterface::load(). + */ + public function load($name) { + $path = $this->getFullPath($name); + if (file_exists($path)) { + include_once($path); + return TRUE; + } + return FALSE; + } + + /** + * Implements Drupal\Component\PhpStorage\PhpStorageInterface::save(). + */ + public function save($name, $code) { + $path = $this->getFullPath($name); + mkdir(dirname($path), 0700, TRUE); + return (bool) file_put_contents($path, $code); + } + + /** + * Implements Drupal\Component\PhpStorage\PhpStorageInterface::delete(). + */ + public function delete($name) { + return @unlink($this->getFullPath($name)); + } + + /** + * Returns the full path where the file is or should be stored. + */ + protected function getFullPath($name) { + return $this->directory . '/' . $name; + } +} diff --git a/core/lib/Drupal/Component/PhpStorage/MTimeProtectedFileStorage.php b/core/lib/Drupal/Component/PhpStorage/MTimeProtectedFileStorage.php new file mode 100644 index 0000000..2401c60 --- /dev/null +++ b/core/lib/Drupal/Component/PhpStorage/MTimeProtectedFileStorage.php @@ -0,0 +1,200 @@ +secret = $configuration['secret']; + } + + /** + * Implements Drupal\Component\PhpStorage\PhpStorageInterface::save(). + */ + public function save($name, $data) { + $this->ensureDirectory(); + + // Write the file out to a temporary location. Prepend with a '.' to keep it + // hidden from listings and web servers. + $temporary_path = $this->directory . '/.' . str_replace('/', '#', $name); + if (!@file_put_contents($temporary_path, $data)) { + return FALSE; + } + chmod($temporary_path, 0400); + + // Prepare a directory dedicated for just this file. Ensure it has a current + // mtime so that when the file (hashed on that mtime) is moved into it, the + // mtime remains the same (unless the clock ticks to the next second during + // the rename, in which case we'll try again). + $directory = $this->getContainingDirectoryFullPath($name); + if (file_exists($directory)) { + $this->cleanDirectory($directory); + touch($directory); + } + else { + mkdir($directory); + } + + // Move the file to its final place. The mtime of a directory is the time of + // the last file create or delete in the directory. So the moving will + // update the directory mtime. However, this update will very likely not + // show up, because it has a coarse, one second granularity and typical + // moves takes significantly less than that. In the unlucky case the clock + // ticks during the move, we need to keep trying until the mtime we hashed + // on and the updated mtime match. + $previous_mtime = 0; + $i = 0; + while (($mtime = $this->getUncachedMTime($directory)) && ($mtime != $previous_mtime)) { + $previous_mtime = $mtime; + chmod($directory, 0300); + // Reset the file back in the temporary location if this is not the first + // iteration. + if ($i > 0) { + rename($full_path, $temporary_path); + // Make sure to not loop infinitely on a hopelessly slow filesystem. + if ($i > 10) { + unlink($temporary_path); + return FALSE; + } + } + $full_path = $this->getFullPath($name, $directory, $mtime); + rename($temporary_path, $full_path); + + // Leave the directory neither readable nor writable. Since the file + // itself is not writable (set to 0400 at the beginning of this function), + // there's no way to tamper with it without access to change permissions. + chmod($directory, 0100); + $i++; + } + return TRUE; + } + + /** + * Implements Drupal\Component\PhpStorage\PhpStorageInterface::delete(). + */ + public function delete($name) { + $directory = dirname($this->getFullPath($name)); + if (file_exists($directory)) { + $this->cleanDirectory($directory); + return rmdir($directory); + } + return FALSE; + } + + /** + * Ensures the root directory exists and has correct permissions. + */ + protected function ensureDirectory() { + if (!file_exists($this->directory)) { + mkdir($this->directory, 0700, TRUE); + } + chmod($this->directory, 0700); + + // In case the root directory is web accessible, add extra protection. + $htaccess_file = $this->directory . '/.htaccess'; + if (!file_exists($htaccess_file)) { + file_put_contents($htaccess_file, "SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006\nDeny from all\nOptions None\nOptions +FollowSymLinks"); + chmod($htaccess_file, 0400); + } + } + + /** + * Removes everything in a directory, leaving it empty. + * + * @param $directory + * The directory to be emptied out. + */ + protected function cleanDirectory($directory) { + chmod($directory, 0700); + foreach (new DirectoryIterator($directory) as $fileinfo) { + if (!$fileinfo->isDot()) { + unlink($fileinfo->getPathName()); + } + } + } + + /** + * Returns the full path where the file is or should be stored. + * + * This function creates a file path that includes a unique containing + * directory for the file and a file name that is a hash of the virtual file + * name, a cryptographic secret, and the containing directory mtime. If the + * file is overridden by an insecure upload script, the directory mtime gets + * modified, invalidating the file, thus protecting against untrusted code + * getting executed. + * + * @param string $name + * The virtual file name. Can be a relative path. + * @param string $directory + * (optional) The directory containing the file. If not passed, this is + * retrieved by calling getContainingDirectoryFullPath(). + * @param int $directory_mtime + * (optional) The mtime of $directory. Can be passed to avoid an extra + * filesystem call when the mtime of the directory is already known. + */ + protected function getFullPath($name, $directory = NULL, $directory_mtime = NULL) { + if (!isset($directory)) { + $directory = $this->getContainingDirectoryFullPath($name); + } + if (!isset($directory_mtime)) { + $directory_mtime = file_exists($directory) ? filemtime($directory) : 0; + } + return $directory . '/' . hash_hmac('sha256', $name, $this->secret . $directory_mtime) . '.php'; + } + + /** + * Returns the full path of the containing directory where the file is or should be stored. + */ + protected function getContainingDirectoryFullPath($name) { + return $this->directory . '/' . str_replace('/', '#', $name); + } + + /** + * Clears PHP's stat cache and returns the directory's mtime. + */ + protected function getUncachedMTime($directory) { + clearstatcache(); + return filemtime($directory); + } +} diff --git a/core/lib/Drupal/Component/PhpStorage/PhpStorageInterface.php b/core/lib/Drupal/Component/PhpStorage/PhpStorageInterface.php new file mode 100644 index 0000000..af455d8 --- /dev/null +++ b/core/lib/Drupal/Component/PhpStorage/PhpStorageInterface.php @@ -0,0 +1,66 @@ +originalFileDirectory . '/simpletest/' . substr($this->databasePrefix, 10)); + file_unmanaged_delete_recursive($this->originalFileDirectory . '/simpletest/' . substr($this->databasePrefix, 10), array($this, 'filePreDeleteCallback')); // Restore original database connection. Database::removeConnection('default'); @@ -939,4 +939,16 @@ abstract class TestBase { } return $all_permutations; } + + /** + * Ensures test files are deletable within file_unmanaged_delete_recursive(). + * + * Some tests chmod generated files to be read only. During tearDown() and + * other cleanup operations, these files need to get deleted too. + */ + public static function filePreDeleteCallback($path) { + if (file_exists($path)) { + chmod($path, 0700); + } + } } diff --git a/core/modules/simpletest/simpletest.module b/core/modules/simpletest/simpletest.module index 4138916..d31a2e3 100644 --- a/core/modules/simpletest/simpletest.module +++ b/core/modules/simpletest/simpletest.module @@ -507,7 +507,7 @@ function simpletest_clean_temporary_directories() { foreach ($files as $file) { $path = 'public://simpletest/' . $file; if (is_dir($path) && (is_numeric($file) || strpos($file, 'config_simpletest') !== FALSE)) { - file_unmanaged_delete_recursive($path); + file_unmanaged_delete_recursive($path, array('Drupal\simpletest\TestBase', 'filePreDeleteCallback')); $count++; } } diff --git a/core/modules/system/lib/Drupal/system/Tests/PhpStorage/FileStorageTest.php b/core/modules/system/lib/Drupal/system/Tests/PhpStorage/FileStorageTest.php new file mode 100644 index 0000000..8c35342 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/PhpStorage/FileStorageTest.php @@ -0,0 +1,40 @@ + 'Simple file storage', + 'description' => 'Tests the FileStorage implementation.', + 'group' => 'PHP Storage', + ); + } + + function setUp() { + global $conf; + parent::setUp(); + $conf['php_storage']['simpletest'] = array( + 'class' => 'Drupal\Component\PhpStorage\FileStorage', + 'directory' => DRUPAL_ROOT . '/' . variable_get('file_public_path', conf_path() . '/files') . '/php', + ); + } + + /** + * Tests the FileStorage implementation. + */ + function testFileStorage() { + $php = drupal_php_storage('simpletest'); + $this->assertIdentical(get_class($php), 'Drupal\Component\PhpStorage\FileStorage'); + $this->doTest($php); + } +} diff --git a/core/modules/system/lib/Drupal/system/Tests/PhpStorage/MTimeProtectedFileStorageTest.php b/core/modules/system/lib/Drupal/system/Tests/PhpStorage/MTimeProtectedFileStorageTest.php new file mode 100644 index 0000000..f59b012 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/PhpStorage/MTimeProtectedFileStorageTest.php @@ -0,0 +1,88 @@ + 'MTime protected file storage', + 'description' => 'Tests the MTimeProtectedFileStorage implementation.', + 'group' => 'PHP Storage', + ); + } + + function setUp() { + global $conf; + parent::setUp(); + $conf['php_storage']['simpletest'] = array( + 'class' => 'Drupal\Component\PhpStorage\MTimeProtectedFileStorage', + 'directory' => DRUPAL_ROOT . '/' . variable_get('file_public_path', conf_path() . '/files') . '/php', + 'secret' => $GLOBALS['drupal_hash_salt'], + ); + } + + /** + * Tests the MTimeProtectedFileStorage implementation. + */ + function testMTimeProtectedFileStorage() { + $php = drupal_php_storage('simpletest'); + $this->assertIdentical(get_class($php), 'Drupal\Component\PhpStorage\MTimeProtectedFileStorage'); + $this->doTest($php); + } + + /** + * Tests the security of the MTimeProtectedFileStorage implementation. + */ + function testSecurity() { + $php = drupal_php_storage('simpletest'); + $name = 'simpletest.php'; + $php->save($name, 'assertTrue(file_exists($expected_filename)); + $this->assertIdentical(fileperms($expected_filename) & 0777, 0400); + $this->assertIdentical(fileperms($expected_directory) & 0777, 0100); + + // Ensure the root directory for the bin has a .htaccess file denying web + // access. + $this->assertIdentical(file_get_contents($expected_root_directory . '/.htaccess'), "SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006\nDeny from all\nOptions None\nOptions +FollowSymLinks"); + + // Ensure that if the file is replaced with an untrusted one (due to another + // script's file upload vulnerability), it does not get loaded. Since mtime + // granularity is 1 second, we cannot prevent an attack that happens within + // a second of the initial save(). However, it is very unlikely for an + // attacker exploiting a mere upload vulnerability to also know when a + // legitimate file is being saved, discover its hash, undo its file + // permissions, and override the file with an upload all within a single + // second. Being able to accomplish that would indicate a site very likely + // vulnerable to many other attack vectors. + sleep(1); + $hacked = FALSE; + $untrusted_code = "assertIdentical(file_get_contents($expected_filename), $untrusted_code); + $success = $php->load($name); + $this->assertIdentical($success, FALSE); + $this->assertIdentical($hacked, FALSE); + $this->assertIdentical($php->exists($name), FALSE); + } +} diff --git a/core/modules/system/lib/Drupal/system/Tests/PhpStorage/PhpStorageTestBase.php b/core/modules/system/lib/Drupal/system/Tests/PhpStorage/PhpStorageTestBase.php new file mode 100644 index 0000000..b141e55 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/PhpStorage/PhpStorageTestBase.php @@ -0,0 +1,51 @@ +randomName() . '/' . $this->randomName() . '.php'; + + // Find a function name that doesn't exist. + do { + $random = mt_rand(10000, 100000); + $function = 'test' . $random; + } while (function_exists($function)); + + // Write out a PHP file and ensure it's successfully loaded. + $code = "save($name, $code); + $this->assertIdentical($success, TRUE); + $success = $php->load($name); + $this->assertIdentical($success, TRUE); + $this->assertIdentical($function(), $random); + + // If the file was successfully loaded, it must also exist, but ensure the + // exists() method returns that correctly. + $this->assertIdentical($php->exists($name), TRUE); + + // Delete the file, and then ensure exists() and load() return FALSE. + $success = $php->delete($name); + $this->assertIdentical($success, TRUE); + $this->assertIdentical($php->exists($name), FALSE); + $this->assertIdentical($php->load($name), FALSE); + + // Ensure delete() can be called on a non-existing file. It should return + // FALSE, but not trigger errors. + $this->assertIdentical($php->delete($name), FALSE); + } +}