3 namespace Drupal\Component\PhpStorage;
5 use Drupal\Component\Utility\Crypt;
8 * Stores PHP code in files with securely hashed names.
10 * The goal of this class is to ensure that if a PHP file is replaced with
11 * an untrusted one, it does not get loaded. Since mtime granularity is 1
12 * second, we cannot prevent an attack that happens within one second of the
13 * initial save(). However, it is very unlikely for an attacker exploiting an
14 * upload or file write vulnerability to also know when a legitimate file is
15 * being saved, discover its hash, undo its file permissions, and override the
16 * file with an upload all within a single second. Being able to accomplish
17 * that would indicate a site very likely vulnerable to many other attack
20 * Each file is stored in its own unique containing directory. The hash is based
21 * on the virtual file name, the containing directory's mtime, and a
22 * cryptographically hard to guess secret string. Thus, even if the hashed file
23 * name is discovered and replaced by an untrusted file (e.g., via a
24 * move_uploaded_file() invocation by a script that performs insufficient
25 * validation), the directory's mtime gets updated in the process, invalidating
26 * the hash and preventing the untrusted file from getting loaded.
28 * This class does not protect against overwriting a file in-place (e.g. a
29 * malicious module that does a file_put_contents()) since this will not change
30 * the mtime of the directory. MTimeProtectedFileStorage protects against this
31 * at the cost of an additional system call for every load() and exists().
33 * The containing directory is created with the same name as the virtual file
34 * name (slashes removed) to assist with debugging, since the file itself is
35 * stored with a name that's meaningless to humans.
37 class MTimeProtectedFastFileStorage extends FileStorage {
40 * The secret used in the HMAC.
47 * Constructs this MTimeProtectedFastFileStorage object.
49 * @param array $configuration
50 * An associated array, containing at least these keys (the rest are
52 * - directory: The directory where the files should be stored.
53 * - secret: A cryptographically hard to guess secret string.
54 * -bin. The storage bin. Multiple storage objects can be instantiated with
55 * the same configuration, but for different bins.
57 public function __construct(array $configuration) {
58 parent::__construct($configuration);
59 $this->secret = $configuration['secret'];
65 public function save($name, $data) {
66 $this->ensureDirectory($this->directory);
68 // Write the file out to a temporary location. Prepend with a '.' to keep it
69 // hidden from listings and web servers.
70 $temporary_path = $this->tempnam($this->directory, '.');
71 if (!$temporary_path || !@file_put_contents($temporary_path, $data)) {
74 // The file will not be chmod() in the future so this is the final
76 chmod($temporary_path, 0444);
78 // Determine the exact modification time of the file.
79 $mtime = $this->getUncachedMTime($temporary_path);
81 // Move the temporary file into the proper directory. Note that POSIX
82 // compliant systems as well as modern Windows perform the rename operation
83 // atomically, i.e. there is no point at which another process attempting to
84 // access the new path will find it missing.
85 $directory = $this->getContainingDirectoryFullPath($name);
86 $this->ensureDirectory($directory);
87 $full_path = $this->getFullPath($name, $directory, $mtime);
88 $result = rename($temporary_path, $full_path);
90 // Finally reset the modification time of the directory to match the one of
91 // the newly created file. In order to prevent the creation of a file if the
92 // directory does not exist, ensure that the path terminates with a
93 // directory separator.
95 // Recall that when subsequently loading the file, the hash is calculated
96 // based on the file name, the containing mtime, and a the secret string.
97 // Hence updating the mtime here is comparable to pointing a symbolic link
98 // at a new target, i.e., the newly created file.
100 $result &= touch($directory . '/', $mtime);
103 return (bool) $result;
107 * Gets the full path where the file is or should be stored.
109 * This function creates a file path that includes a unique containing
110 * directory for the file and a file name that is a hash of the virtual file
111 * name, a cryptographic secret, and the containing directory mtime. If the
112 * file is overridden by an insecure upload script, the directory mtime gets
113 * modified, invalidating the file, thus protecting against untrusted code
116 * @param string $name
117 * The virtual file name. Can be a relative path.
118 * @param string $directory
119 * (optional) The directory containing the file. If not passed, this is
120 * retrieved by calling getContainingDirectoryFullPath().
121 * @param int $directory_mtime
122 * (optional) The mtime of $directory. Can be passed to avoid an extra
123 * filesystem call when the mtime of the directory is already known.
126 * The full path where the file is or should be stored.
128 public function getFullPath($name, &$directory = NULL, &$directory_mtime = NULL) {
129 if (!isset($directory)) {
130 $directory = $this->getContainingDirectoryFullPath($name);
132 if (!isset($directory_mtime)) {
133 $directory_mtime = file_exists($directory) ? filemtime($directory) : 0;
135 return $directory . '/' . Crypt::hmacBase64($name, $this->secret . $directory_mtime) . '.php';
141 public function delete($name) {
142 $path = $this->getContainingDirectoryFullPath($name);
143 if (file_exists($path)) {
144 return $this->unlink($path);
152 public function garbageCollection() {
153 $flags = \FilesystemIterator::CURRENT_AS_FILEINFO;
154 $flags += \FilesystemIterator::SKIP_DOTS;
156 foreach ($this->listAll() as $name) {
157 $directory = $this->getContainingDirectoryFullPath($name);
159 $dir_iterator = new \FilesystemIterator($directory, $flags);
161 catch (\UnexpectedValueException $e) {
162 // FilesystemIterator throws an UnexpectedValueException if the
163 // specified path is not a directory, or if it is not accessible.
167 $directory_unlink = TRUE;
168 $directory_mtime = filemtime($directory);
169 foreach ($dir_iterator as $fileinfo) {
170 if ($directory_mtime > $fileinfo->getMTime()) {
171 // Ensure the folder is writable.
172 @chmod($directory, 0777);
173 @unlink($fileinfo->getPathName());
176 // The directory still contains valid files.
177 $directory_unlink = FALSE;
181 if ($directory_unlink) {
182 $this->unlink($name);
188 * Gets the full path of the containing directory where the file is or should
191 * @param string $name
192 * The virtual file name. Can be a relative path.
195 * The full path of the containing directory where the file is or should be
198 protected function getContainingDirectoryFullPath($name) {
199 // Remove the .php file extension from the directory name.
200 // Within a single directory, a subdirectory cannot have the same name as a
201 // file. Thus, when switching between MTimeProtectedFastFileStorage and
202 // FileStorage, the subdirectory or the file cannot be created in case the
203 // other file type exists already.
204 if (substr($name, -4) === '.php') {
205 $name = substr($name, 0, -4);
207 return $this->directory . '/' . str_replace('/', '#', $name);
211 * Clears PHP's stat cache and returns the directory's mtime.
213 protected function getUncachedMTime($directory) {
214 clearstatcache(TRUE, $directory);
215 return filemtime($directory);
219 * A brute force tempnam implementation supporting streams.
222 * The directory where the temporary filename will be created.
224 * The prefix of the generated temporary filename.
226 * Returns the new temporary filename (with path), or FALSE on failure.
228 protected function tempnam($directory, $prefix) {
230 $path = $directory . '/' . $prefix . Crypt::randomBytesBase64(20);
231 } while (file_exists($path));